From f4073c051af140390114969e263c00f7fb5156c3 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Wed, 7 Jan 2026 03:23:35 -0800 Subject: [PATCH 01/11] feat: Migrate authentication from NextAuth v5 to Better Auth - Update Prisma schema with Better Auth models (User, Session, Account, Verification, Passkey, TwoFactor, Jwks) - Change User.id from CUID to UUID format - Change emailVerified from DateTime? to Boolean - Add cross-subdomain cookie support for SSO on .usefoundry.ai domain - Create Better Auth configuration for builder and viewer apps - Update SignInForm and SocialLoginButtons to use Better Auth client - Add public environment variables for auth provider visibility - Configure shared database sessions for SSO across apps BREAKING: Requires new environment variables BETTER_AUTH_URL and BETTER_AUTH_SECRET Brian Johnson in useFoundry.ai (cherry picked from commit 1c39a71bf834d0c422d62f8230a9196289cfe4d3) --- .gitignore | 1 + apps/builder/package.json | 4 +- .../src/app/api/auth/[...all]/route.ts | 4 + .../src/app/api/auth/[...nextauth]/route.ts | 31 --- .../features/auth/components/SignInForm.tsx | 56 ++--- .../auth/components/SocialLoginButtons.tsx | 107 ++++----- .../auth/helpers/createAuthPrismaAdapter.ts | 204 ------------------ .../auth/helpers/getAuthenticatedUser.ts | 16 +- .../builder/src/features/auth/lib/nextAuth.ts | 193 ----------------- .../src/features/auth/lib/providers.ts | 145 ------------- apps/builder/src/features/auth/next-auth.d.ts | 7 - apps/builder/src/lib/auth/client.ts | 25 +++ apps/builder/src/lib/auth/config.ts | 120 +++++++++++ apps/builder/src/lib/auth/providers.ts | 56 +++++ .../src/lib/auth/send-verification-email.ts | 37 ++++ apps/viewer/package.json | 1 + .../viewer/src/app/api/auth/[...all]/route.ts | 4 + apps/viewer/src/lib/auth/client.ts | 9 + apps/viewer/src/lib/auth/config.ts | 35 +++ bun.lock | 57 +++-- packages/env/src/index.ts | 32 ++- packages/prisma/postgresql/schema.prisma | 192 ++++++++++++----- 22 files changed, 598 insertions(+), 738 deletions(-) create mode 100644 apps/builder/src/app/api/auth/[...all]/route.ts delete mode 100644 apps/builder/src/app/api/auth/[...nextauth]/route.ts delete mode 100644 apps/builder/src/features/auth/helpers/createAuthPrismaAdapter.ts delete mode 100644 apps/builder/src/features/auth/lib/nextAuth.ts delete mode 100644 apps/builder/src/features/auth/lib/providers.ts delete mode 100644 apps/builder/src/features/auth/next-auth.d.ts create mode 100644 apps/builder/src/lib/auth/client.ts create mode 100644 apps/builder/src/lib/auth/config.ts create mode 100644 apps/builder/src/lib/auth/providers.ts create mode 100644 apps/builder/src/lib/auth/send-verification-email.ts create mode 100644 apps/viewer/src/app/api/auth/[...all]/route.ts create mode 100644 apps/viewer/src/lib/auth/client.ts create mode 100644 apps/viewer/src/lib/auth/config.ts diff --git a/.gitignore b/.gitignore index da387ecdc0..41509bf962 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ snapshots .nitro **/test/.auth +.env*.local diff --git a/apps/builder/package.json b/apps/builder/package.json index 31a293ff4f..3c06d929cf 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -9,8 +9,9 @@ "test": "dotenv -e ./.env -e ../../.env -- bun test" }, "dependencies": { - "@auth/core": "^0.39.1", + "@better-auth/expo": "^1.4.10", "@braintree/sanitize-url": "^7.0.1", + "better-auth": "^1.4.10", "@dnd-kit/helpers": "^0.1.21", "@dnd-kit/react": "^0.1.21", "@giphy/js-fetch-api": "^5.7.0", @@ -77,7 +78,6 @@ "micro-cors": "^0.1.1", "nanoid": "^5.1.5", "next": "^15.5.9", - "next-auth": "^5.0.0-beta.28", "next-themes": "^0.4.6", "nextjs-cors": "^2.1.2", "nodemailer": "^7.0.6", diff --git a/apps/builder/src/app/api/auth/[...all]/route.ts b/apps/builder/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000000..d441e7ea66 --- /dev/null +++ b/apps/builder/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth/config"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/builder/src/app/api/auth/[...nextauth]/route.ts b/apps/builder/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 3ab54a4def..0000000000 --- a/apps/builder/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { - authHandlers, - SET_TYPEBOT_COOKIE_HEADER, -} from "@/features/auth/lib/nextAuth"; - -export const GET = async (req: NextRequest) => { - const response = await authHandlers.GET(req); - setTypebotCookie(req, response); - return response; -}; - -export const POST = async (req: NextRequest) => { - const response = await authHandlers.POST(req); - setTypebotCookie(req, response); - return response; -}; - -// Accept company firewall requests? Not sure if it is still necessary -export const HEAD = () => { - return NextResponse.json({ message: "ok" }, { status: 200 }); -}; - -// Because we don't have access to `res` in nextAuth.ts -const setTypebotCookie = (req: NextRequest, response: Response) => { - const typebotSetCookieHeaderValue = req.headers.get( - SET_TYPEBOT_COOKIE_HEADER, - ); - if (typebotSetCookieHeaderValue) - response.headers.append("Set-Cookie", typebotSetCookieHeaderValue); -}; diff --git a/apps/builder/src/features/auth/components/SignInForm.tsx b/apps/builder/src/features/auth/components/SignInForm.tsx index 006a939286..029c80ac38 100644 --- a/apps/builder/src/features/auth/components/SignInForm.tsx +++ b/apps/builder/src/features/auth/components/SignInForm.tsx @@ -9,12 +9,12 @@ import { CheckmarkSquare02Icon } from "@typebot.io/ui/icons/CheckmarkSquare02Ico import { LoaderCircleIcon } from "@typebot.io/ui/icons/LoaderCircleIcon"; import { cn } from "@typebot.io/ui/lib/cn"; import { useRouter } from "next/navigation"; -import { getProviders, signIn, useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import type { FormEvent } from "react"; import { useEffect, useState } from "react"; import { TextLink } from "@/components/TextLink"; import { toast } from "@/lib/toast"; +import { authClient, useSession } from "@/lib/auth/client"; import { createEmailMagicLink } from "../helpers/createEmailMagicLink"; import { DividerWithText } from "./DividerWithText"; import { SignInError } from "./SignInError"; @@ -25,35 +25,36 @@ type Props = { className?: string; }; +// Available providers configuration (matches server config) +const availableProviders = { + github: !!process.env.NEXT_PUBLIC_GITHUB_ENABLED, + google: !!process.env.NEXT_PUBLIC_GOOGLE_ENABLED, + facebook: !!process.env.NEXT_PUBLIC_FACEBOOK_ENABLED, + gitlab: !!process.env.NEXT_PUBLIC_GITLAB_ENABLED, + microsoft: !!process.env.NEXT_PUBLIC_AZURE_ENABLED, + email: !!process.env.NEXT_PUBLIC_EMAIL_ENABLED, +}; + export const SignInForm = ({ defaultEmail, className }: Props) => { const { t } = useTranslate(); const router = useRouter(); const [authError, setAuthError] = useQueryState("error"); const [redirectPath] = useQueryState("redirectPath"); - const { status } = useSession(); + const { data: session, isPending } = useSession(); const [authLoading, setAuthLoading] = useState(false); - const [isLoadingProviders, setIsLoadingProviders] = useState(true); + const [isLoadingProviders, setIsLoadingProviders] = useState(false); const [emailValue, setEmailValue] = useState(defaultEmail ?? ""); const [isMagicCodeSent, setIsMagicCodeSent] = useState(false); - const [providers, setProviders] = - useState>>(); - - const hasNoAuthProvider = - !isLoadingProviders && Object.keys(providers ?? {}).length === 0; + // Check if any provider is configured + const hasNoAuthProvider = !Object.values(availableProviders).some(Boolean); useEffect(() => { - if (status === "authenticated") { + if (session?.user) { router.replace(redirectPath ? sanitizeUrl(redirectPath) : "/typebots"); - return; } - (async () => { - const providers = await getProviders(); - setProviders(providers ?? undefined); - setIsLoadingProviders(false); - })(); - }, [status, router]); + }, [session, router, redirectPath]); useEffect(() => { if (authError === "ip-banned") { @@ -71,29 +72,29 @@ export const SignInForm = ({ defaultEmail, className }: Props) => { if (isMagicCodeSent) return; setAuthLoading(true); try { - const response = await signIn("nodemailer", { + const { error } = await authClient.signIn.magicLink({ email: emailValue, - redirect: false, + callbackURL: redirectPath ? sanitizeUrl(redirectPath) : "/typebots", }); - if (response?.error) { - if (response.error.includes("too-many-requests")) + if (error) { + if (error.message?.includes("too-many-requests")) toast({ type: "info", description: t("auth.signinErrorToast.tooManyRequests"), }); - else if (response.error.includes("sign-up-disabled")) + else if (error.message?.includes("sign-up-disabled")) toast({ type: "info", description: t("auth.signinErrorToast.title"), }); - else if (response.error.includes("email-not-legit")) + else if (error.message?.includes("email-not-legit")) toast({ description: "Please use a valid email address", }); else toast({ description: t("errorMessage"), - details: "Check server logs to see relevent error message.", + details: error.message || "Check server logs to see relevent error message.", }); } else { setIsMagicCodeSent(true); @@ -113,7 +114,7 @@ export const SignInForm = ({ defaultEmail, className }: Props) => { ); }; - if (isLoadingProviders) return ; + if (isPending) return ; if (hasNoAuthProvider) return (

@@ -130,8 +131,8 @@ export const SignInForm = ({ defaultEmail, className }: Props) => {

{!isMagicCodeSent && ( <> - - {providers?.nodemailer && ( + + {availableProviders.email && ( <> {t("auth.orEmailLabel")}
{ )} - {providers?.google && ( + {availableProviders.google && ( )} - {providers?.facebook && ( + {availableProviders.facebook && ( )} - {providers?.gitlab && ( + {availableProviders.gitlab && ( )} - {providers?.["microsoft-entra-id"] && ( + {availableProviders.microsoft && ( )} - {providers?.["custom-oauth"] && ( + {availableProviders.customOAuth && ( )} - {providers?.keycloak && ( + {availableProviders.keycloak && (
); diff --git a/apps/builder/src/features/auth/components/SocialLoginButtons.tsx b/apps/builder/src/features/auth/components/SocialLoginButtons.tsx index fe8164def9..4e4962b18e 100644 --- a/apps/builder/src/features/auth/components/SocialLoginButtons.tsx +++ b/apps/builder/src/features/auth/components/SocialLoginButtons.tsx @@ -11,19 +11,13 @@ import { FacebookLogo } from "@/components/logos/FacebookLogo"; import { GitlabLogo } from "@/components/logos/GitlabLogo"; import { KeycloackLogo } from "@/components/logos/KeycloakLogo"; import { authClient, useSession } from "@/lib/auth/client"; +import type { AvailableProviders } from "@/lib/auth/getAvailableProviders"; -// Available providers configuration -const availableProviders = { - github: !!process.env.NEXT_PUBLIC_GITHUB_ENABLED, - google: !!process.env.NEXT_PUBLIC_GOOGLE_ENABLED, - facebook: !!process.env.NEXT_PUBLIC_FACEBOOK_ENABLED, - gitlab: !!process.env.NEXT_PUBLIC_GITLAB_ENABLED, - microsoft: !!process.env.NEXT_PUBLIC_AZURE_ENABLED, - keycloak: !!process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED, - customOAuth: !!process.env.NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED, +type Props = { + availableProviders: AvailableProviders; }; -export const SocialLoginButtons = () => { +export const SocialLoginButtons = ({ availableProviders }: Props) => { const { t } = useTranslate(); const { query } = useRouter(); const { data: session, isPending } = useSession(); @@ -135,7 +129,7 @@ export const SocialLoginButtons = () => { variant="outline-secondary" > {t("auth.socialLogin.customButton.label", { - customProviderName: process.env.NEXT_PUBLIC_CUSTOM_OAUTH_NAME || "SSO", + customProviderName: availableProviders.customOAuthName || "SSO", })} )} diff --git a/apps/builder/src/lib/auth/getAvailableProviders.ts b/apps/builder/src/lib/auth/getAvailableProviders.ts new file mode 100644 index 0000000000..99c27320a6 --- /dev/null +++ b/apps/builder/src/lib/auth/getAvailableProviders.ts @@ -0,0 +1,45 @@ +import { env } from "@typebot.io/env"; + +export type AvailableProviders = { + github: boolean; + google: boolean; + facebook: boolean; + gitlab: boolean; + microsoft: boolean; + keycloak: boolean; + customOAuth: boolean; + customOAuthName?: string; + email: boolean; +}; + +/** + * Server-side function to compute available auth providers + * based on which OAuth credentials are configured. + * This avoids the need for separate NEXT_PUBLIC_*_ENABLED flags. + */ +export function getAvailableProviders(): AvailableProviders { + return { + github: !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET), + google: !!(env.GOOGLE_AUTH_CLIENT_ID && env.GOOGLE_AUTH_CLIENT_SECRET), + facebook: !!(env.FACEBOOK_CLIENT_ID && env.FACEBOOK_CLIENT_SECRET), + gitlab: !!(env.GITLAB_CLIENT_ID && env.GITLAB_CLIENT_SECRET), + microsoft: !!( + env.AZURE_AD_CLIENT_ID && + env.AZURE_AD_CLIENT_SECRET && + env.AZURE_AD_TENANT_ID + ), + keycloak: !!( + env.KEYCLOAK_CLIENT_ID && + env.KEYCLOAK_CLIENT_SECRET && + env.KEYCLOAK_REALM && + env.KEYCLOAK_BASE_URL + ), + customOAuth: !!( + env.CUSTOM_OAUTH_CLIENT_ID && + env.CUSTOM_OAUTH_CLIENT_SECRET && + env.CUSTOM_OAUTH_ISSUER + ), + customOAuthName: env.CUSTOM_OAUTH_NAME, + email: !!(env.NEXT_PUBLIC_SMTP_FROM && !env.SMTP_AUTH_DISABLED), + }; +} diff --git a/apps/builder/src/pages/register.tsx b/apps/builder/src/pages/register.tsx index d9f3679193..0fe0ff2705 100644 --- a/apps/builder/src/pages/register.tsx +++ b/apps/builder/src/pages/register.tsx @@ -1,5 +1,22 @@ +import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; import { SignInPage } from "@/features/auth/components/SignInPage"; +import { + getAvailableProviders, + type AvailableProviders, +} from "@/lib/auth/getAvailableProviders"; -export default function Page() { - return ; +export const getServerSideProps: GetServerSideProps<{ + availableProviders: AvailableProviders; +}> = async () => { + return { + props: { + availableProviders: getAvailableProviders(), + }, + }; +}; + +export default function Page({ + availableProviders, +}: InferGetServerSidePropsType) { + return ; } diff --git a/apps/builder/src/pages/signin.tsx b/apps/builder/src/pages/signin.tsx index bb37d0b69c..7eb6d70948 100644 --- a/apps/builder/src/pages/signin.tsx +++ b/apps/builder/src/pages/signin.tsx @@ -1,5 +1,22 @@ +import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; import { SignInPage } from "@/features/auth/components/SignInPage"; +import { + getAvailableProviders, + type AvailableProviders, +} from "@/lib/auth/getAvailableProviders"; -export default function Page() { - return ; +export const getServerSideProps: GetServerSideProps<{ + availableProviders: AvailableProviders; +}> = async () => { + return { + props: { + availableProviders: getAvailableProviders(), + }, + }; +}; + +export default function Page({ + availableProviders, +}: InferGetServerSidePropsType) { + return ; } From 4a4153a4e1af0432216d0da3a3dc2388406f2601 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 18:09:49 -0800 Subject: [PATCH 05/11] refactor: Replace NEXTAUTH_URL with BETTER_AUTH_URL throughout codebase Complete migration to Better Auth by removing all NEXTAUTH_URL references: - Remove NEXTAUTH_URL from env schema (packages/env/src/index.ts) - Replace all env.NEXTAUTH_URL usages with env.BETTER_AUTH_URL - Update turbo.json env configurations - Update .env.example and .env.dev.example - Update GitHub workflow files - Update CLAUDE.md documentation This makes the codebase purely Better Auth based with no NextAuth remnants. Brian Johnson in useFoundry.ai (cherry picked from commit 61e26b559b0370f0a0ab88cb0076af9a666ce53b) --- .env.dev.example | 3 +- .env.example | 3 +- .github/workflows/daily.yml | 2 +- .github/workflows/hourly.yml | 2 +- .github/workflows/monthly.yml | 2 +- .../api/[blockType]/oauth/authorize/route.ts | 2 +- apps/builder/src/components/Seo.tsx | 2 +- .../googleSheets/api/getAccessToken.ts | 2 +- .../credentials/api/createOAuthCredentials.ts | 2 +- .../credentials/api/updateOAuthCredentials.ts | 2 +- .../src/features/theme/galleryTemplates.ts | 2 +- .../features/typebot/api/publishTypebot.ts | 2 +- .../src/helpers/isCloudProdInstance.ts | 2 +- .../src/helpers/isSelfHostedInstance.ts | 4 +- apps/builder/src/lib/queryClient.ts | 2 +- .../api/credentials/google-sheets/callback.ts | 2 +- .../credentials/google-sheets/consent-url.ts | 2 +- .../api/typebots/[typebotId]/invitations.ts | 2 +- .../workspaces/[workspaceId]/invitations.ts | 4 +- apps/viewer/next.config.mjs | 14 +- apps/viewer/src/pages/[[...publicId]].tsx | 2 +- apps/viewer/src/test/analytics.spec.ts | 2 +- apps/viewer/src/test/fileUpload.spec.ts | 2 +- apps/viewer/src/test/results.spec.ts | 2 +- apps/viewer/src/test/sendEmail.spec.ts | 2 +- apps/viewer/src/test/typebotLink.spec.ts | 6 +- .../billing/src/api/getBillingPortalUrl.ts | 2 +- .../deprecated/handleGenerateUploadUrlV1.ts | 2 +- .../deprecated/handleGenerateUploadUrlV2.ts | 2 +- .../src/api/handleGenerateUploadUrl.ts | 2 +- .../chatwoot/executeChatwootBlock.ts | 2 +- .../sendEmail/executeSendEmailBlock.tsx | 4 +- packages/emails/marketing/V2dot22Update.tsx | 6 +- packages/emails/marketing/V2dot23Update.tsx | 6 +- packages/emails/marketing/V2dot24Update.tsx | 6 +- packages/emails/marketing/V2dot26Update.tsx | 2 +- packages/emails/marketing/V3dot6Update.tsx | 2 +- .../marketing/components/NewsletterLayout.tsx | 4 +- .../InactiveWorkspaceFirstNoticeEmail.tsx | 2 +- .../emails/transactional/components/Logo.tsx | 2 +- packages/env/src/index.ts | 5 - packages/lib/src/s3/uploadFileToBucket.ts | 2 +- .../helpers/checkAndReportLastHourResults.ts | 304 +++++------------- packages/whatsapp/src/resumeWhatsAppFlow.ts | 2 +- turbo.json | 8 +- 45 files changed, 141 insertions(+), 298 deletions(-) diff --git a/.env.dev.example b/.env.dev.example index d3bb78e6b1..5f00369e3a 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -2,7 +2,8 @@ ENCRYPTION_SECRET=H+KbL/OFrqbEuDy/1zX8bsPG+spXri3S DATABASE_URL=postgresql://postgres:typebot@localhost:5432/typebot -NEXTAUTH_URL=http://localhost:3000 +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_VIEWER_URL=http://localhost:3001 GITHUB_CLIENT_ID=534b549dd17709a743a2 diff --git a/.env.example b/.env.example index 88c118659e..54bd0b9ca3 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ DATABASE_URL=postgresql://postgres:typebot@typebot-db:5432/typebot NODE_OPTIONS=--no-node-snapshot -NEXTAUTH_URL= +BETTER_AUTH_URL= +NEXT_PUBLIC_BETTER_AUTH_URL= NEXT_PUBLIC_VIEWER_URL= ADMIN_EMAIL= diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 4f196a25f5..c1b033dbfe 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -14,7 +14,7 @@ jobs: DATABASE_URL: "${{ secrets.DATABASE_URL }}" DATABASE_URL_REPLICA: "${{ secrets.DATABASE_URL_REPLICA }}" ENCRYPTION_SECRET: "${{ secrets.ENCRYPTION_SECRET }}" - NEXTAUTH_URL: "http://localhost:3000" + BETTER_AUTH_URL: "http://localhost:3000" NEXT_PUBLIC_VIEWER_URL: "http://localhost:3001" MESSAGE_WEBHOOK_URL: "${{ secrets.MESSAGE_WEBHOOK_URL }}" POSTHOG_PROJECT_ID: "${{ secrets.POSTHOG_PROJECT_ID }}" diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml index 6abf7d61d4..b59cb62fb7 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -14,7 +14,7 @@ jobs: DATABASE_URL: "${{ secrets.DATABASE_URL }}" DATABASE_URL_REPLICA: "${{ secrets.DATABASE_URL_REPLICA }}" ENCRYPTION_SECRET: "${{ secrets.ENCRYPTION_SECRET }}" - NEXTAUTH_URL: "${{ secrets.NEXTAUTH_URL }}" + BETTER_AUTH_URL: "${{ secrets.BETTER_AUTH_URL }}" NEXT_PUBLIC_VIEWER_URL: "${{ secrets.NEXT_PUBLIC_VIEWER_URL }}" NEXT_PUBLIC_POSTHOG_KEY: "${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" POSTHOG_API_HOST: "${{ secrets.POSTHOG_API_HOST }}" diff --git a/.github/workflows/monthly.yml b/.github/workflows/monthly.yml index f4016355cb..1bbae17285 100644 --- a/.github/workflows/monthly.yml +++ b/.github/workflows/monthly.yml @@ -14,7 +14,7 @@ jobs: DATABASE_URL: "${{ secrets.DATABASE_URL }}" DATABASE_URL_REPLICA: "${{ secrets.DATABASE_URL_REPLICA }}" ENCRYPTION_SECRET: "${{ secrets.ENCRYPTION_SECRET }}" - NEXTAUTH_URL: "http://localhost:3000" + BETTER_AUTH_URL: "http://localhost:3000" NEXT_PUBLIC_VIEWER_URL: "http://localhost:3001" steps: - uses: actions/checkout@v2 diff --git a/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts b/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts index 2638e189e3..f652ecc8da 100644 --- a/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts +++ b/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts @@ -35,7 +35,7 @@ export const GET = async ( const urlParams = { response_type: "code", client_id: clientId, - redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + redirect_uri: `${env.BETTER_AUTH_URL}/oauth/redirect`, scope: authConfig.scopes.join(" "), ...authConfig.extraAuthParams, }; diff --git a/apps/builder/src/components/Seo.tsx b/apps/builder/src/components/Seo.tsx index 99a3f3861e..636e18767b 100644 --- a/apps/builder/src/components/Seo.tsx +++ b/apps/builder/src/components/Seo.tsx @@ -6,7 +6,7 @@ const getOrigin = () => { return window.location.origin; } - return env.NEXTAUTH_URL; + return env.BETTER_AUTH_URL; }; export const Seo = ({ diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/getAccessToken.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/getAccessToken.ts index efd1eed49b..0137a0f24d 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/api/getAccessToken.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/getAccessToken.ts @@ -54,7 +54,7 @@ export const getAccessToken = authenticatedProcedure const client = new OAuth2Client({ clientId: env.GOOGLE_SHEETS_CLIENT_ID, clientSecret: env.GOOGLE_SHEETS_CLIENT_SECRET, - redirectUri: `${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`, + redirectUri: `${env.BETTER_AUTH_URL}/api/credentials/google-sheets/callback`, }); client.setCredentials(decryptedCredentials); diff --git a/apps/builder/src/features/credentials/api/createOAuthCredentials.ts b/apps/builder/src/features/credentials/api/createOAuthCredentials.ts index cf0446dc06..afdf5a01c2 100644 --- a/apps/builder/src/features/credentials/api/createOAuthCredentials.ts +++ b/apps/builder/src/features/credentials/api/createOAuthCredentials.ts @@ -131,7 +131,7 @@ const exchangeCodeForTokens = async ({ code: code, client_id: client.id, client_secret: client.secret, - redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + redirect_uri: `${env.BETTER_AUTH_URL}/oauth/redirect`, }, }) .json(); diff --git a/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts b/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts index 76e7cb3593..79e8eeb1d9 100644 --- a/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts +++ b/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts @@ -105,7 +105,7 @@ const exchangeCodeForTokens = async ({ code: code, client_id: client.id, client_secret: client.secret, - redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + redirect_uri: `${env.BETTER_AUTH_URL}/oauth/redirect`, }, }) .json(); diff --git a/apps/builder/src/features/theme/galleryTemplates.ts b/apps/builder/src/features/theme/galleryTemplates.ts index 6199213738..f0e72fa79d 100644 --- a/apps/builder/src/features/theme/galleryTemplates.ts +++ b/apps/builder/src/features/theme/galleryTemplates.ts @@ -16,7 +16,7 @@ const getOrigin = () => { return window.location.origin; } - return env.NEXTAUTH_URL; + return env.BETTER_AUTH_URL; }; export const galleryTemplates: (Pick & { diff --git a/apps/builder/src/features/typebot/api/publishTypebot.ts b/apps/builder/src/features/typebot/api/publishTypebot.ts index 1fb0eb571f..88f76c1370 100644 --- a/apps/builder/src/features/typebot/api/publishTypebot.ts +++ b/apps/builder/src/features/typebot/api/publishTypebot.ts @@ -125,7 +125,7 @@ export const publishTypebot = authenticatedProcedure if (riskLevel > 0 && riskLevel !== existingTypebot.riskLevel) { if (riskLevel !== 100 && riskLevel > 60) await sendMessage( - `⚠️ Suspicious typebot to be reviewed: ${existingTypebot.name} (${env.NEXTAUTH_URL}/typebots/${existingTypebot.id}/edit) (workspace: ${existingTypebot.workspaceId})`, + `⚠️ Suspicious typebot to be reviewed: ${existingTypebot.name} (${env.BETTER_AUTH_URL}/typebots/${existingTypebot.id}/edit) (workspace: ${existingTypebot.workspaceId})`, ); await prisma.typebot.updateMany({ diff --git a/apps/builder/src/helpers/isCloudProdInstance.ts b/apps/builder/src/helpers/isCloudProdInstance.ts index e9111f3c20..4bdf8f7c9b 100644 --- a/apps/builder/src/helpers/isCloudProdInstance.ts +++ b/apps/builder/src/helpers/isCloudProdInstance.ts @@ -4,5 +4,5 @@ export const isCloudProdInstance = () => { if (typeof window !== "undefined") { return window.location.hostname === "app.typebot.io"; } - return env.NEXTAUTH_URL === "https://app.typebot.io"; + return env.BETTER_AUTH_URL === "https://app.typebot.io"; }; diff --git a/apps/builder/src/helpers/isSelfHostedInstance.ts b/apps/builder/src/helpers/isSelfHostedInstance.ts index a7890e8bdf..e928bfb93e 100644 --- a/apps/builder/src/helpers/isSelfHostedInstance.ts +++ b/apps/builder/src/helpers/isSelfHostedInstance.ts @@ -8,7 +8,7 @@ export const isSelfHostedInstance = () => { ); } return ( - env.NEXTAUTH_URL !== "https://app.typebot.io" && - !env.NEXTAUTH_URL.startsWith("http://localhost") + env.BETTER_AUTH_URL !== "https://app.typebot.io" && + !env.BETTER_AUTH_URL.startsWith("http://localhost") ); }; diff --git a/apps/builder/src/lib/queryClient.ts b/apps/builder/src/lib/queryClient.ts index bc285035cd..2e39698c1b 100644 --- a/apps/builder/src/lib/queryClient.ts +++ b/apps/builder/src/lib/queryClient.ts @@ -68,7 +68,7 @@ export const trpcClient = createTRPCClient({ httpLink({ url: (() => { if (typeof window === "undefined") - return `${env.NEXTAUTH_URL}/api/trpc`; + return `${env.BETTER_AUTH_URL}/api/trpc`; return `${window.location.origin}/api/trpc`; })(), transformer: superjson, diff --git a/apps/builder/src/pages/api/credentials/google-sheets/callback.ts b/apps/builder/src/pages/api/credentials/google-sheets/callback.ts index 4d4f7ff4d4..6a4096c1c4 100644 --- a/apps/builder/src/pages/api/credentials/google-sheets/callback.ts +++ b/apps/builder/src/pages/api/credentials/google-sheets/callback.ts @@ -32,7 +32,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const oauth2Client = new OAuth2Client( env.GOOGLE_SHEETS_CLIENT_ID, env.GOOGLE_SHEETS_CLIENT_SECRET, - `${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`, + `${env.BETTER_AUTH_URL}/api/credentials/google-sheets/callback`, ); const { tokens } = await oauth2Client.getToken(code); if (!tokens?.access_token) { diff --git a/apps/builder/src/pages/api/credentials/google-sheets/consent-url.ts b/apps/builder/src/pages/api/credentials/google-sheets/consent-url.ts index c8f21b7914..e25c001ce2 100644 --- a/apps/builder/src/pages/api/credentials/google-sheets/consent-url.ts +++ b/apps/builder/src/pages/api/credentials/google-sheets/consent-url.ts @@ -13,7 +13,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => { const oauth2Client = new OAuth2Client( env.GOOGLE_SHEETS_CLIENT_ID, env.GOOGLE_SHEETS_CLIENT_SECRET, - `${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`, + `${env.BETTER_AUTH_URL}/api/credentials/google-sheets/callback`, ); const url = oauth2Client.generateAuthUrl({ access_type: "offline", diff --git a/apps/builder/src/pages/api/typebots/[typebotId]/invitations.ts b/apps/builder/src/pages/api/typebots/[typebotId]/invitations.ts index 8c6f3ab9a0..0f86320e62 100644 --- a/apps/builder/src/pages/api/typebots/[typebotId]/invitations.ts +++ b/apps/builder/src/pages/api/typebots/[typebotId]/invitations.ts @@ -88,7 +88,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { }); await sendGuestInvitationEmail({ hostEmail: user.email ?? "", - url: `${env.NEXTAUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`, + url: `${env.BETTER_AUTH_URL}/typebots?workspaceId=${typebot.workspaceId}`, guestEmail: email.toLowerCase(), typebotName: typebot.name, workspaceName: typebot.workspace?.name ?? "", diff --git a/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts b/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts index 65196ff298..ee916a5b79 100644 --- a/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts +++ b/apps/builder/src/pages/api/workspaces/[workspaceId]/invitations.ts @@ -66,7 +66,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { await sendWorkspaceMemberInvitationEmail({ workspaceName: workspace.name, guestEmail: data.email, - url: `${env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`, + url: `${env.BETTER_AUTH_URL}/typebots?workspaceId=${workspace.id}`, hostEmail: user.email ?? "", }); return res.send({ @@ -83,7 +83,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { await sendWorkspaceMemberInvitationEmail({ workspaceName: workspace.name, guestEmail: data.email, - url: `${env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`, + url: `${env.BETTER_AUTH_URL}/typebots?workspaceId=${workspace.id}`, hostEmail: user.email ?? "", }); return res.send({ invitation }); diff --git a/apps/viewer/next.config.mjs b/apps/viewer/next.config.mjs index ba570b1903..ca7367bf2b 100644 --- a/apps/viewer/next.config.mjs +++ b/apps/viewer/next.config.mjs @@ -133,37 +133,37 @@ const nextConfig = { }, ]) .concat( - process.env.NEXTAUTH_URL + process.env.BETTER_AUTH_URL ? [ { source: "/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/sampleResult", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`, }, { source: "/api/typebots/:typebotId/blocks/:blockId/sampleResult", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`, }, { source: "/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/unsubscribeWebhook", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`, }, { source: "/api/typebots/:typebotId/blocks/:blockId/unsubscribeWebhook", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`, }, { source: "/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/subscribeWebhook", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`, }, { source: "/api/typebots/:typebotId/blocks/:blockId/subscribeWebhook", - destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`, + destination: `${process.env.BETTER_AUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`, }, ] : [], diff --git a/apps/viewer/src/pages/[[...publicId]].tsx b/apps/viewer/src/pages/[[...publicId]].tsx index dca1e749dd..62226ebab7 100644 --- a/apps/viewer/src/pages/[[...publicId]].tsx +++ b/apps/viewer/src/pages/[[...publicId]].tsx @@ -56,7 +56,7 @@ export const getServerSideProps: GetServerSideProps = async ( // Early return, will just show a root page return { props: { - dashboardUrl: `${env.NEXTAUTH_URL ?? "https://app.typebot.io"}/typebots`, + dashboardUrl: `${env.BETTER_AUTH_URL ?? "https://app.typebot.io"}/typebots`, }, }; } diff --git a/apps/viewer/src/test/analytics.spec.ts b/apps/viewer/src/test/analytics.spec.ts index f2acb7fc4a..d405101e35 100644 --- a/apps/viewer/src/test/analytics.spec.ts +++ b/apps/viewer/src/test/analytics.spec.ts @@ -12,7 +12,7 @@ test("should work as expected", async ({ page: botPage, context }) => { }); const analyticsPage = await context.newPage(); await analyticsPage.goto( - `${env.NEXTAUTH_URL}/typebots/${typebotId}/results/analytics`, + `${env.BETTER_AUTH_URL}/typebots/${typebotId}/results/analytics`, ); await expect(analyticsPage.getByTestId("dropoff-edge-1")).toBeHidden(); await botPage.goto(`/${typebotId}-public`); diff --git a/apps/viewer/src/test/fileUpload.spec.ts b/apps/viewer/src/test/fileUpload.spec.ts index ce812530a2..2ce0f54896 100644 --- a/apps/viewer/src/test/fileUpload.spec.ts +++ b/apps/viewer/src/test/fileUpload.spec.ts @@ -23,7 +23,7 @@ test("should work as expected", async ({ page, browser }) => { ]); await page.locator('text="Upload 3 files"').click(); await expect(page.locator(`text="3 files uploaded"`)).toBeVisible(); - await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`); + await page.goto(`${env.BETTER_AUTH_URL}/typebots/${typebotId}/results`); await expect(page.getByRole("link", { name: "api.json" })).toHaveAttribute( "href", /.+\/api\.json/, diff --git a/apps/viewer/src/test/results.spec.ts b/apps/viewer/src/test/results.spec.ts index b2973f0092..90fe7687e4 100644 --- a/apps/viewer/src/test/results.spec.ts +++ b/apps/viewer/src/test/results.spec.ts @@ -21,7 +21,7 @@ test("Big groups should work as expected", async ({ page }) => { await page.locator("input").press("Enter"); await expect(page.getByText("26")).toBeVisible(); await page.getByRole("button", { name: "Yes" }).click(); - await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`); + await page.goto(`${env.BETTER_AUTH_URL}/typebots/${typebotId}/results`); await expect(page.locator('text="Baptiste"')).toBeVisible({ timeout: 10000, }); diff --git a/apps/viewer/src/test/sendEmail.spec.ts b/apps/viewer/src/test/sendEmail.spec.ts index 3159dfc12e..1e2e24efcb 100644 --- a/apps/viewer/src/test/sendEmail.spec.ts +++ b/apps/viewer/src/test/sendEmail.spec.ts @@ -35,7 +35,7 @@ test("should send an email", async ({ page }) => { await page.goto(`/${typebotId}-public`); await page.locator("text=Send email").click(); await expect(page.getByText("Email sent!")).toBeVisible(); - await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`); + await page.goto(`${env.BETTER_AUTH_URL}/typebots/${typebotId}/results`); await page.click('text="See logs"'); await expect(page.locator('text="Email successfully sent"')).toBeVisible(); }); diff --git a/apps/viewer/src/test/typebotLink.spec.ts b/apps/viewer/src/test/typebotLink.spec.ts index 5cce85bacc..c8df0b156e 100644 --- a/apps/viewer/src/test/typebotLink.spec.ts +++ b/apps/viewer/src/test/typebotLink.spec.ts @@ -36,7 +36,7 @@ test("should work as expected", async ({ page }) => { await expect(page.getByText("end 3")).toBeVisible(); await expect(page.getByText("End", { exact: true })).toBeVisible(); await page.goto( - `${env.NEXTAUTH_URL}/typebots/${publicTypebot1.typebotId}/results`, + `${env.BETTER_AUTH_URL}/typebots/${publicTypebot1.typebotId}/results`, ); await expect(page.locator("text=Hello there!")).toBeVisible(); }); @@ -48,12 +48,12 @@ test.describe("Merge disabled", () => { await page.getByPlaceholder("Type your answer...").press("Enter"); await expect(page.getByText("Cheers!")).toBeVisible(); await page.goto( - `${process.env.NEXTAUTH_URL}/typebots/${publicTypebot1MergeDisabled.typebotId}/results`, + `${process.env.BETTER_AUTH_URL}/typebots/${publicTypebot1MergeDisabled.typebotId}/results`, ); await expect(page.locator("text=Submitted at")).toBeVisible(); await expect(page.locator("text=Hello there!")).toBeHidden(); await page.goto( - `${env.NEXTAUTH_URL}/typebots/${publicTypebot2.typebotId}/results`, + `${env.BETTER_AUTH_URL}/typebots/${publicTypebot2.typebotId}/results`, ); await expect(page.locator("text=Hello there!")).toBeVisible(); }); diff --git a/packages/billing/src/api/getBillingPortalUrl.ts b/packages/billing/src/api/getBillingPortalUrl.ts index 5a0ac61419..130feb48b8 100644 --- a/packages/billing/src/api/getBillingPortalUrl.ts +++ b/packages/billing/src/api/getBillingPortalUrl.ts @@ -39,7 +39,7 @@ export const getBillingPortalUrl = async ({ workspaceId, user }: Props) => { }); const portalSession = await stripe.billingPortal.sessions.create({ customer: workspace.stripeId, - return_url: `${env.NEXTAUTH_URL}/typebots`, + return_url: `${env.BETTER_AUTH_URL}/typebots`, }); return { billingPortalUrl: portalSession.url, diff --git a/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV1.ts b/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV1.ts index 3635a01b39..a2790f6136 100644 --- a/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV1.ts +++ b/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV1.ts @@ -159,7 +159,7 @@ export const handleGenerateUploadUrlV1 = async ({ formData: presignedPostPolicy.formData, fileUrl: fileUploadBlock.options?.visibility === "Private" - ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` + ? `${env.BETTER_AUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` : env.S3_PUBLIC_CUSTOM_DOMAIN ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, diff --git a/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV2.ts b/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV2.ts index f54f863c0f..b9f7fc666c 100644 --- a/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV2.ts +++ b/packages/blocks/fileInput/src/api/deprecated/handleGenerateUploadUrlV2.ts @@ -92,7 +92,7 @@ export const handleGenerateUploadUrlV2 = async ({ formData: presignedPostPolicy.formData, fileUrl: visibility === "Private" && !isPreview - ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}` + ? `${env.BETTER_AUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}` : env.S3_PUBLIC_CUSTOM_DOMAIN ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, diff --git a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts index 1d4cff9e89..157f62c94e 100644 --- a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts +++ b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts @@ -114,7 +114,7 @@ export const handleGenerateUploadUrl = async ({ formData: presignedPostPolicy.formData, fileUrl: visibility === "Private" && !isPreview - ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/blocks/${blockId}/${fileName}` + ? `${env.BETTER_AUTH_URL}/api/typebots/${typebotId}/results/${resultId}/blocks/${blockId}/${fileName}` : env.S3_PUBLIC_CUSTOM_DOMAIN ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, diff --git a/packages/bot-engine/src/blocks/integrations/chatwoot/executeChatwootBlock.ts b/packages/bot-engine/src/blocks/integrations/chatwoot/executeChatwootBlock.ts index 5371c92175..8304c84ea2 100644 --- a/packages/bot-engine/src/blocks/integrations/chatwoot/executeChatwootBlock.ts +++ b/packages/bot-engine/src/blocks/integrations/chatwoot/executeChatwootBlock.ts @@ -34,7 +34,7 @@ const parseChatwootOpenCode = ({ if(window.Typebot?.unmount) window.Typebot.unmount(); window.$chatwoot.setCustomAttributes({ typebot_result_url: "${ - env.NEXTAUTH_URL + env.BETTER_AUTH_URL }/typebots/${typebotId}/results?id=${resultId}", }); window.$chatwoot.toggle("open"); diff --git a/packages/bot-engine/src/blocks/integrations/sendEmail/executeSendEmailBlock.tsx b/packages/bot-engine/src/blocks/integrations/sendEmail/executeSendEmailBlock.tsx index 155c83aac5..2745452b94 100644 --- a/packages/bot-engine/src/blocks/integrations/sendEmail/executeSendEmailBlock.tsx +++ b/packages/bot-engine/src/blocks/integrations/sendEmail/executeSendEmailBlock.tsx @@ -301,7 +301,7 @@ const getEmailBody = async ({ }); return { html: await renderDefaultBotNotificationEmail({ - resultsUrl: `${env.NEXTAUTH_URL}/typebots/${typebot.id}/results`, + resultsUrl: `${env.BETTER_AUTH_URL}/typebots/${typebot.id}/results`, answers: omit(answers, "submittedAt"), }), }; @@ -348,7 +348,7 @@ const parseAttachments = ( const urls = Array.isArray(fileUrls) ? fileUrls : fileUrls.split(", "); return Promise.all( urls.map(async (url) => { - if (!url.startsWith(env.NEXTAUTH_URL)) return { path: url }; + if (!url.startsWith(env.BETTER_AUTH_URL)) return { path: url }; const { typebotId: urlTypebotId, resultId, diff --git a/packages/emails/marketing/V2dot22Update.tsx b/packages/emails/marketing/V2dot22Update.tsx index 7abacfc7e1..9b5fc6e6b1 100644 --- a/packages/emails/marketing/V2dot22Update.tsx +++ b/packages/emails/marketing/V2dot22Update.tsx @@ -29,7 +29,7 @@ type Props = { firstName?: string; }; -const imagesBaseUrl = `${env.NEXTAUTH_URL}/images/emails/V2dot22Update`; +const imagesBaseUrl = `${env.BETTER_AUTH_URL}/images/emails/V2dot22Update`; export const V2dot22Update = ({}: Props) => ( @@ -38,7 +38,7 @@ export const V2dot22Update = ({}: Props) => ( Typebot's Logo ( Baptiste. Typebot's Logo ( @@ -37,7 +37,7 @@ export const V2dot23Update = ({}: Props) => ( Typebot's Logo ( Baptiste. Typebot's Logo ( @@ -37,7 +37,7 @@ export const V2dot24Update = ({}: Props) => ( Typebot's Logo ( Baptiste. Typebot's Logo ( diff --git a/packages/emails/marketing/V3dot6Update.tsx b/packages/emails/marketing/V3dot6Update.tsx index 8fa17bd066..c7c7e4bbe5 100644 --- a/packages/emails/marketing/V3dot6Update.tsx +++ b/packages/emails/marketing/V3dot6Update.tsx @@ -4,7 +4,7 @@ import { NewsletterLayout } from "./components/NewsletterLayout"; import { NewsletterSection } from "./components/NewsletterSection"; import { hr, text } from "./styles"; -const imagesBaseUrl = `${env.NEXTAUTH_URL}/images/emails/V3dot6Update`; +const imagesBaseUrl = `${env.BETTER_AUTH_URL}/images/emails/V3dot6Update`; export const V3dot6Update = () => ( diff --git a/packages/emails/marketing/components/NewsletterLayout.tsx b/packages/emails/marketing/components/NewsletterLayout.tsx index b4cb649c25..b985137878 100644 --- a/packages/emails/marketing/components/NewsletterLayout.tsx +++ b/packages/emails/marketing/components/NewsletterLayout.tsx @@ -22,7 +22,7 @@ export const NewsletterLayout = ({ preview, children }: Props) => ( Typebot's Logo ( /> {children} Typebot's Logo To keep your workspace active, just{" "} log in to your Typebot account {" "} diff --git a/packages/emails/transactional/components/Logo.tsx b/packages/emails/transactional/components/Logo.tsx index fe16c4aa79..c7f59a4da9 100644 --- a/packages/emails/transactional/components/Logo.tsx +++ b/packages/emails/transactional/components/Logo.tsx @@ -5,7 +5,7 @@ import React from "react"; export const Logo = () => ( Typebot's Logo { }, }); - if (isEmpty(env.STRIPE_SECRET_KEY)) + if (isEmpty(process.env.STRIPE_SECRET_KEY)) throw new Error("Missing STRIPE_SECRET_KEY env variable"); - const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-09-30.acacia", }); const limitWarningEmailEvents: TelemetryEvent[] = []; const quarantineEvents: TelemetryEvent[] = []; const autoUpgradeEvents: TelemetryEvent[] = []; - const billingCycleResetEvents: TelemetryEvent[] = []; for (const workspace of workspaces) { if (workspace.isQuarantined) continue; @@ -86,8 +81,8 @@ export const checkAndReportLastHourResults = async () => { const isUsageBasedSubscription = isDefined( subscription?.items.data.find( (item) => - item.price.id === env.STRIPE_STARTER_PRICE_ID || - item.price.id === env.STRIPE_PRO_PRICE_ID, + item.price.id === process.env.STRIPE_STARTER_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_PRICE_ID, ), ); @@ -108,14 +103,17 @@ export const checkAndReportLastHourResults = async () => { autoUpgradeEvents.push( ...workspace.members .filter((member) => member.role === WorkspaceRole.ADMIN) - .map((member) => ({ - name: "Subscription automatically updated" as const, - userId: member.user.id, - workspaceId: workspace.id, - data: { - plan: Plan.PRO, - }, - })), + .map( + (member) => + ({ + name: "Subscription automatically updated", + userId: member.user.id, + workspaceId: workspace.id, + data: { + plan: "PRO", + }, + }) satisfies TelemetryEvent, + ), ); await reportUsageToStripe(totalChatsUsed, { stripe, @@ -135,85 +133,21 @@ export const checkAndReportLastHourResults = async () => { quarantineEvents.push( ...workspace.members .filter((member) => member.role === WorkspaceRole.ADMIN) - .map((member) => ({ - name: "Workspace automatically quarantined" as const, - userId: member.user.id, - workspaceId: workspace.id, - data: { - reason: "auto upgrade payment failed" as const, - }, - })), + .map( + (member) => + ({ + name: "Workspace automatically quarantined", + userId: member.user.id, + workspaceId: workspace.id, + data: { + reason: "auto upgrade payment failed", + }, + }) satisfies TelemetryEvent, + ), ); } } else { await reportUsageToStripe(totalChatsUsed, { stripe, subscription }); - - if (workspace.plan === "PRO") { - const isSuspicious = await isSuspiciousWorkspace( - subscription, - totalChatsUsed, - workspace.plan, - { stripe }, - ); - - if (isSuspicious) { - console.log( - `Suspicious Pro workspace ${workspace.id} collected ${totalChatsUsed} chats, resetting billing cycle...`, - ); - const adminMembers = workspace.members.filter( - (member) => member.role === WorkspaceRole.ADMIN, - ); - const adminEmails = adminMembers - .map((member) => member.user.email) - .filter(isDefined); - - try { - await chargeAndResetBillingCycle(subscription, { stripe }); - console.log( - `Successfully reset billing cycle for workspace ${workspace.id}`, - ); - await sendBillingCycleResetEmail({ - to: adminEmails, - workspaceName: workspace.name, - totalChatsUsed, - url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`, - }); - billingCycleResetEvents.push( - ...adminMembers.map((member) => ({ - name: "Billing cycle reset" as const, - userId: member.user.id, - workspaceId: workspace.id, - })), - ); - continue; - } catch (error) { - console.error( - `Failed to reset billing cycle for workspace ${workspace.id}, quarantining...`, - error, - ); - await sendBillingCycleResetFailedEmail({ - to: adminEmails, - workspaceName: workspace.name, - totalChatsUsed, - }); - await prisma.workspace.updateMany({ - where: { id: workspace.id }, - data: { isQuarantined: true }, - }); - quarantineEvents.push( - ...adminMembers.map((member) => ({ - name: "Workspace automatically quarantined" as const, - userId: member.user.id, - workspaceId: workspace.id, - data: { - reason: - "suspicious billing cycle reset payment failed" as const, - }, - })), - ); - } - } - } } } @@ -230,16 +164,19 @@ export const checkAndReportLastHourResults = async () => { quarantineEvents.push( ...workspace.members .filter((member) => member.role === WorkspaceRole.ADMIN) - .map((member) => ({ - name: "Workspace automatically quarantined" as const, - userId: member.user.id, - workspaceId: workspace.id, - data: { - totalChatsUsed, - chatsLimit: workspace.chatsHardLimit ?? chatsLimit, - reason: "free limit reached" as const, - }, - })), + .map( + (member) => + ({ + name: "Workspace automatically quarantined", + userId: member.user.id, + workspaceId: workspace.id, + data: { + totalChatsUsed, + chatsLimit: workspace.chatsHardLimit ?? chatsLimit, + reason: "free limit reached", + }, + }) satisfies TelemetryEvent, + ), ); } } @@ -257,12 +194,7 @@ export const checkAndReportLastHourResults = async () => { }, }); - await trackEvents( - limitWarningEmailEvents - .concat(quarantineEvents) - .concat(autoUpgradeEvents) - .concat(billingCycleResetEvents), - ); + await trackEvents(limitWarningEmailEvents.concat(quarantineEvents)); }; const getSubscription = async ( @@ -293,14 +225,17 @@ const reportUsageToStripe = async ( subscription, }: { stripe: Stripe; subscription: Stripe.Subscription }, ) => { - if (!env.STRIPE_STARTER_CHATS_PRICE_ID || !env.STRIPE_PRO_CHATS_PRICE_ID) + if ( + !process.env.STRIPE_STARTER_CHATS_PRICE_ID || + !process.env.STRIPE_PRO_CHATS_PRICE_ID + ) throw new Error( "Missing STRIPE_STARTER_CHATS_PRICE_ID or STRIPE_PRO_CHATS_PRICE_ID env variable", ); const subscriptionItem = subscription.items.data.find( (item) => - item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID || - item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID, + item.price.id === process.env.STRIPE_STARTER_CHATS_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_CHATS_PRICE_ID, ); if (!subscriptionItem) @@ -364,18 +299,19 @@ const autoUpgradeToPro = async ( | { status: "error"; reason: "payment_required" | "unknown" } > => { if ( - !env.STRIPE_STARTER_CHATS_PRICE_ID || - !env.STRIPE_PRO_CHATS_PRICE_ID || - !env.STRIPE_PRO_PRICE_ID || - !env.STRIPE_STARTER_PRICE_ID + !process.env.STRIPE_STARTER_CHATS_PRICE_ID || + !process.env.STRIPE_PRO_CHATS_PRICE_ID || + !process.env.STRIPE_PRO_PRICE_ID || + !process.env.STRIPE_STARTER_PRICE_ID ) throw new Error( "Missing STRIPE_STARTER_CHATS_PRICE_ID or STRIPE_PRO_CHATS_PRICE_ID env variable", ); const currentPlanItemId = subscription?.items.data.find((item) => - [env.STRIPE_PRO_PRICE_ID, env.STRIPE_STARTER_PRICE_ID].includes( - item.price.id, - ), + [ + process.env.STRIPE_PRO_PRICE_ID, + process.env.STRIPE_STARTER_PRICE_ID, + ].includes(item.price.id), )?.id; if (!currentPlanItemId) @@ -385,16 +321,16 @@ const autoUpgradeToPro = async ( items: [ { id: currentPlanItemId, - price: env.STRIPE_PRO_PRICE_ID, + price: process.env.STRIPE_PRO_PRICE_ID, quantity: 1, }, { id: subscription.items.data.find( (item) => - item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID || - item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID, + item.price.id === process.env.STRIPE_STARTER_CHATS_PRICE_ID || + item.price.id === process.env.STRIPE_PRO_CHATS_PRICE_ID, )?.id, - price: env.STRIPE_PRO_CHATS_PRICE_ID, + price: process.env.STRIPE_PRO_CHATS_PRICE_ID, }, ], proration_behavior: "always_invoice", @@ -454,11 +390,14 @@ async function sendLimitWarningEmails({ workspaceName: workspace.name, }); emailEvents.push( - ...adminMembers.map((m) => ({ - name: "Limit warning email sent" as const, - userId: m.user.id, - workspaceId: workspace.id, - })), + ...adminMembers.map( + (m) => + ({ + name: "Limit warning email sent", + userId: m.user.id, + workspaceId: workspace.id, + }) satisfies TelemetryEvent, + ), ); await prisma.workspace.updateMany({ where: { id: workspace.id }, @@ -479,14 +418,17 @@ async function sendLimitWarningEmails({ await sendReachedChatsLimitEmail({ to, chatsLimit: limit, - url: `${env.NEXTAUTH_URL}/typebots?workspaceId=${workspace.id}`, + url: `${process.env.BETTER_AUTH_URL}/typebots?workspaceId=${workspace.id}`, }); emailEvents.push( - ...adminMembers.map((m) => ({ - name: "Limit reached email sent" as const, - userId: m.user.id, - workspaceId: workspace.id, - })), + ...adminMembers.map( + (m) => + ({ + name: "Limit reached email sent", + userId: m.user.id, + workspaceId: workspace.id, + }) satisfies TelemetryEvent, + ), ); await prisma.workspace.updateMany({ where: { id: workspace.id }, @@ -545,99 +487,3 @@ export const getLastHourActiveTypebotIds = async () => { return results.map((r) => r.typebotId); }; - -const isSuspiciousWorkspace = async ( - subscription: Stripe.Subscription, - totalChatsUsed: number, - plan: "STARTER" | "PRO", - { stripe }: { stripe: Stripe }, -) => { - if (plan !== "PRO" || !env.STRIPE_PRO_CHATS_PRICE_ID) return false; - - const proLimit = chatsLimits[Plan.PRO]; - if (totalChatsUsed < proLimit) return false; - - const daysSincePeriodStart = Math.max( - (Date.now() / 1000 - subscription.current_period_start) / 86400, - 1, - ); - const dailyAverage = totalChatsUsed / daysSincePeriodStart; - const expectedDailyRate = proLimit / 30; - - if (dailyAverage <= expectedDailyRate * 3) return false; - - const invoices = await stripe.invoices.list({ - subscription: subscription.id, - status: "paid", - limit: 12, - expand: ["data.lines"], - }); - - const { totalPaidOverageCents, paidInvoiceWithOverageCount } = - invoices.data.reduce( - (acc, invoice) => { - const overageLines = invoice.lines.data.filter( - (line) => - line.price?.id === env.STRIPE_PRO_CHATS_PRICE_ID && line.amount > 0, - ); - const overageAmount = overageLines.reduce( - (sum, line) => sum + line.amount, - 0, - ); - return { - totalPaidOverageCents: acc.totalPaidOverageCents + overageAmount, - paidInvoiceWithOverageCount: - acc.paidInvoiceWithOverageCount + (overageLines.length > 0 ? 1 : 0), - }; - }, - { totalPaidOverageCents: 0, paidInvoiceWithOverageCount: 0 }, - ); - - // Multiple invoices with overage payments establishes a payment pattern - if (paidInvoiceWithOverageCount >= 2) return false; - - const currentOverageValueCents = computeProPlanChatsCost(totalChatsUsed); - - // Trust if they've historically paid at least 30% of equivalent current overage - if ( - totalPaidOverageCents > 0 && - totalPaidOverageCents >= currentOverageValueCents * 0.3 - ) { - return false; - } - - // Trust if subscription is at least 2 months old with at least one paid overage - const cycleLength = 30 * 24 * 60 * 60; - const monthsActive = (Date.now() / 1000 - subscription.created) / cycleLength; - if (monthsActive >= 2 && paidInvoiceWithOverageCount >= 1) return false; - - return true; -}; - -const chargeAndResetBillingCycle = async ( - subscription: Stripe.Subscription, - { stripe }: { stripe: Stripe }, -) => - stripe.subscriptions.update(subscription.id, { - billing_cycle_anchor: "now", - proration_behavior: "create_prorations", - payment_behavior: "error_if_incomplete", - }); - -const computeProPlanChatsCost = (totalChatsUsed: number): number => { - for (const tier of proChatTiers) { - if (tier.up_to === "inf") { - const previousTierLimit = - proChatTiers[proChatTiers.indexOf(tier) - 1]?.up_to; - if (typeof previousTierLimit !== "number") return 0; - const chatsInThisTier = totalChatsUsed - previousTierLimit; - const unitAmount = - "unit_amount_decimal" in tier ? tier.unit_amount_decimal : 0; - return chatsInThisTier * Number(unitAmount); - } - if (totalChatsUsed <= tier.up_to) { - return "flat_amount" in tier ? tier.flat_amount : 0; - } - } - return 0; -}; diff --git a/packages/whatsapp/src/resumeWhatsAppFlow.ts b/packages/whatsapp/src/resumeWhatsAppFlow.ts index 017836933b..2b3653a01a 100644 --- a/packages/whatsapp/src/resumeWhatsAppFlow.ts +++ b/packages/whatsapp/src/resumeWhatsAppFlow.ts @@ -263,7 +263,7 @@ const convertWhatsAppMessageToTypebotMessage = async ({ ? extensionFromMimeType[mimeType] : undefined; fileUrl = - env.NEXTAUTH_URL + + env.BETTER_AUTH_URL + `/api/typebots/${typebotId}/whatsapp/media/${ workspaceId ? `` : "preview/" }${mediaId}${extension ? `.${extension}` : ""}`; diff --git a/turbo.json b/turbo.json index 46c1f4a3e0..73ff41ffb1 100644 --- a/turbo.json +++ b/turbo.json @@ -18,7 +18,7 @@ "persistent": true }, "build": { - "env": ["VERCEL_*", "NEXTAUTH_URL", "SENTRY_*", "LANDING_PAGE_URL"], + "env": ["VERCEL_*", "BETTER_AUTH_URL", "SENTRY_*", "LANDING_PAGE_URL"], "dependsOn": ["^build", "^db:generate"], "outputs": [ ".next/**", @@ -53,17 +53,17 @@ "cache": false }, "cron:hourly": { - "env": ["STRIPE_*", "NEXTAUTH_URL", "SMTP_*"], + "env": ["STRIPE_*", "BETTER_AUTH_URL", "SMTP_*"], "dependsOn": ["@typebot.io/prisma#db:generate"], "cache": false }, "cron:daily": { - "env": ["NEXTAUTH_URL"], + "env": ["BETTER_AUTH_URL"], "dependsOn": ["@typebot.io/prisma#db:generate"], "cache": false }, "cron:monthly": { - "env": ["NEXTAUTH_URL"], + "env": ["BETTER_AUTH_URL"], "dependsOn": ["@typebot.io/prisma#db:generate"], "cache": false }, From e9e179ecb27ec218e96906bae457fe01fe819d75 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 22:56:31 -0800 Subject: [PATCH 06/11] fix: Update Account schema to match Better Auth field names - Rename expiresAt to accessTokenExpiresAt - Add refreshTokenExpiresAt field Brian Johnson in useFoundry.ai (cherry picked from commit 8461b8c5a45034f77b73dbaee426c3971efad402) --- packages/prisma/postgresql/schema.prisma | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/prisma/postgresql/schema.prisma b/packages/prisma/postgresql/schema.prisma index faf576dc5d..7851825934 100644 --- a/packages/prisma/postgresql/schema.prisma +++ b/packages/prisma/postgresql/schema.prisma @@ -85,15 +85,16 @@ model Account { updatedAt DateTime @updatedAt // Better Auth required fields - accountId String - providerId String - accessToken String? - refreshToken String? - expiresAt DateTime? - tokenType String? - scope String? - idToken String? - password String? + accountId String + providerId String + accessToken String? + refreshToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + tokenType String? + scope String? + idToken String? + password String? // Relations userId String From 04e2e7948638c0578c97019ce861ca71194493e0 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 23:01:28 -0800 Subject: [PATCH 07/11] fix: Add BUILDER_URL env var and proper Account schema migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BUILDER_URL env var for viewer to link to builder dashboard - Create proper Prisma migration for Account schema changes: - Rename expiresAt → accessTokenExpiresAt - Add refreshTokenExpiresAt field Brian Johnson in useFoundry.ai (cherry picked from commit 7b28e6623d3e9abe8915992df3d20524121ad680) --- apps/viewer/src/pages/[[...publicId]].tsx | 4 +++- packages/env/src/index.ts | 2 ++ .../migration.sql | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/prisma/postgresql/migrations/20260109070000_fix_account_token_expires_fields/migration.sql diff --git a/apps/viewer/src/pages/[[...publicId]].tsx b/apps/viewer/src/pages/[[...publicId]].tsx index 62226ebab7..fe31d03a8a 100644 --- a/apps/viewer/src/pages/[[...publicId]].tsx +++ b/apps/viewer/src/pages/[[...publicId]].tsx @@ -54,9 +54,11 @@ export const getServerSideProps: GetServerSideProps = async ( log(`isMatchingViewerUrl: ${isMatchingViewerUrl}`); if (isMatchingViewerUrl && pathname === "/") { // Early return, will just show a root page + // Use BUILDER_URL if set, otherwise fall back to BETTER_AUTH_URL + const builderUrl = env.BUILDER_URL ?? env.BETTER_AUTH_URL ?? "https://app.typebot.io"; return { props: { - dashboardUrl: `${env.BETTER_AUTH_URL ?? "https://app.typebot.io"}/typebots`, + dashboardUrl: `${builderUrl}/typebots`, }, }; } diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 83ed93b929..0e30cf4d3f 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -71,6 +71,8 @@ const baseEnv = { guessBetterAuthUrlForVercelPreview, z.string().url(), ), + // Builder app URL (for viewer to link back to dashboard) + BUILDER_URL: z.string().url().optional(), DISABLE_SIGNUP: boolean.optional().default("false"), ADMIN_EMAIL: z .string() diff --git a/packages/prisma/postgresql/migrations/20260109070000_fix_account_token_expires_fields/migration.sql b/packages/prisma/postgresql/migrations/20260109070000_fix_account_token_expires_fields/migration.sql new file mode 100644 index 0000000000..36f3417898 --- /dev/null +++ b/packages/prisma/postgresql/migrations/20260109070000_fix_account_token_expires_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: Rename expiresAt to accessTokenExpiresAt and add refreshTokenExpiresAt +ALTER TABLE "Account" RENAME COLUMN "expiresAt" TO "accessTokenExpiresAt"; +ALTER TABLE "Account" ADD COLUMN IF NOT EXISTS "refreshTokenExpiresAt" TIMESTAMP; From cf82ea51ae716d27e7fbe9103ffde233057822de Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 23:07:37 -0800 Subject: [PATCH 08/11] refactor: Remove hardcoded branding, use env vars instead - Add AUTH_COOKIE_DOMAIN env var for cross-subdomain SSO - Add APP_NAME env var for email branding (defaults to "Typebot") - Remove hardcoded ".usefoundry.ai" cookie domain - Remove hardcoded "Foundry" branding in emails This makes the fork upstream-friendly with no internal references. Brian Johnson in useFoundry.ai (cherry picked from commit 527b9a59fb16593b2ba45220f0975b04de65e77d) --- apps/builder/src/lib/auth/config.ts | 10 ++++++---- apps/builder/src/lib/auth/send-verification-email.ts | 6 ++++-- apps/viewer/src/lib/auth/config.ts | 10 ++++++---- packages/env/src/index.ts | 4 ++++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/builder/src/lib/auth/config.ts b/apps/builder/src/lib/auth/config.ts index 28f25cd019..b113b10766 100644 --- a/apps/builder/src/lib/auth/config.ts +++ b/apps/builder/src/lib/auth/config.ts @@ -18,10 +18,12 @@ export const auth = betterAuth({ // Cross-subdomain SSO configuration advanced: { - crossSubDomainCookies: { - enabled: true, - domain: ".usefoundry.ai", - }, + crossSubDomainCookies: env.AUTH_COOKIE_DOMAIN + ? { + enabled: true, + domain: env.AUTH_COOKIE_DOMAIN, + } + : { enabled: false }, defaultCookieAttributes: { sameSite: "lax", secure: process.env.NODE_ENV === "production", diff --git a/apps/builder/src/lib/auth/send-verification-email.ts b/apps/builder/src/lib/auth/send-verification-email.ts index 50a6552f27..e8f5f8c854 100644 --- a/apps/builder/src/lib/auth/send-verification-email.ts +++ b/apps/builder/src/lib/auth/send-verification-email.ts @@ -1,4 +1,5 @@ import { sendEmail } from "@typebot.io/emails/helpers/sendEmail"; +import { env } from "@typebot.io/env"; type SendVerificationEmailProps = { email: string; @@ -11,9 +12,10 @@ export async function sendVerificationEmail({ url, otp, }: SendVerificationEmailProps): Promise { + const appName = env.APP_NAME; const subject = otp ? `Your verification code: ${otp}` - : "Sign in to your account"; + : `Sign in to ${appName}`; const body = otp ? `Your verification code is: ${otp}

This code expires in 5 minutes.` @@ -24,7 +26,7 @@ export async function sendVerificationEmail({ subject, html: `
-

Sign in to Foundry

+

Sign in to ${appName}

${body}

If you did not request this, you can safely ignore this email. diff --git a/apps/viewer/src/lib/auth/config.ts b/apps/viewer/src/lib/auth/config.ts index 21ed9137a8..d5296056dd 100644 --- a/apps/viewer/src/lib/auth/config.ts +++ b/apps/viewer/src/lib/auth/config.ts @@ -12,10 +12,12 @@ export const auth = betterAuth({ // Cross-subdomain SSO configuration (shared with builder) advanced: { - crossSubDomainCookies: { - enabled: true, - domain: ".usefoundry.ai", - }, + crossSubDomainCookies: env.AUTH_COOKIE_DOMAIN + ? { + enabled: true, + domain: env.AUTH_COOKIE_DOMAIN, + } + : { enabled: false }, defaultCookieAttributes: { sameSite: "lax", secure: process.env.NODE_ENV === "production", diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 0e30cf4d3f..69258b1bb0 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -73,6 +73,10 @@ const baseEnv = { ), // Builder app URL (for viewer to link back to dashboard) BUILDER_URL: z.string().url().optional(), + // Cookie domain for cross-subdomain SSO (e.g., ".example.com") + AUTH_COOKIE_DOMAIN: z.string().optional(), + // App name for branding in emails + APP_NAME: z.string().optional().default("Typebot"), DISABLE_SIGNUP: boolean.optional().default("false"), ADMIN_EMAIL: z .string() From 039bb3ab838d3a8024d3349c9896f00db029f858 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 23:46:09 -0800 Subject: [PATCH 09/11] fix: Create workspace for new users via Better Auth hooks - Fixed getAuthenticatedUser to fetch full user from DB (Better Auth session only returns basic fields, causing schema validation errors) - Added workspace creation in Better Auth's databaseHooks.user.create.after - Fixed onboardingCategories default from "{}" to "[]" (must be array) - Added createMissingWorkspaces script to fix existing users without workspaces This resolves the 500 errors on /api/trpc routes and the "upgrade plan to create folders" issue caused by missing workspace records. Brian Johnson in useFoundry.ai (cherry picked from commit f0da2b3d297d04b7093f06b3ed64bd8ef1c2ad53) --- .../auth/helpers/getAuthenticatedUser.ts | 14 ++- apps/builder/src/lib/auth/config.ts | 106 ++++++++++++++---- packages/prisma/postgresql/schema.prisma | 2 +- .../scripts/src/createMissingWorkspaces.ts | 61 ++++++++++ 4 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 packages/scripts/src/createMissingWorkspaces.ts diff --git a/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts b/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts index 820d95d302..e71347457b 100644 --- a/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts +++ b/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts @@ -3,7 +3,6 @@ import prisma from "@typebot.io/prisma"; import { type ClientUser, clientUserSchema } from "@typebot.io/user/schemas"; import type { NextApiRequest, NextApiResponse } from "next"; import { auth } from "@/lib/auth/config"; -import { headers } from "next/headers"; export const getAuthenticatedUser = async ( req: NextApiRequest, @@ -19,10 +18,17 @@ export const getAuthenticatedUser = async ( }), }); - if (!session?.user) return undefined; + if (!session?.user?.id) return undefined; - Sentry.setUser({ id: session.user.id }); - return clientUserSchema.parse(session.user); + // Fetch full user from database (Better Auth session only has basic fields) + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + }); + + if (!user) return undefined; + + Sentry.setUser({ id: user.id }); + return clientUserSchema.parse(user); }; const authenticateByToken = async ( diff --git a/apps/builder/src/lib/auth/config.ts b/apps/builder/src/lib/auth/config.ts index b113b10766..27a46dee75 100644 --- a/apps/builder/src/lib/auth/config.ts +++ b/apps/builder/src/lib/auth/config.ts @@ -1,13 +1,50 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; -import { magicLink } from "better-auth/plugins/magic-link"; -import { emailOTP } from "better-auth/plugins/email-otp"; import { admin } from "better-auth/plugins/admin"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; +import { magicLink } from "better-auth/plugins/magic-link"; +import { APIError } from "better-auth/api"; import { env } from "@typebot.io/env"; import prisma from "@typebot.io/prisma"; -import { sendVerificationEmail } from "./send-verification-email"; +import { Plan } from "@typebot.io/prisma/enum"; import { getOAuthProviders } from "./providers"; +import { sendVerificationEmail } from "./send-verification-email"; + +/** + * Determines the default workspace plan for a new user. + * Admin emails get UNLIMITED, otherwise uses DEFAULT_WORKSPACE_PLAN or FREE. + */ +function getDefaultWorkspacePlan(userEmail: string): Plan { + if (env.ADMIN_EMAIL?.some((email) => email === userEmail)) { + return Plan.UNLIMITED; + } + const defaultPlan = env.DEFAULT_WORKSPACE_PLAN as Plan; + if (defaultPlan && Object.values(Plan).includes(defaultPlan)) { + return defaultPlan; + } + return Plan.FREE; +} + +/** + * Validates if an email domain is allowed based on ALLOWED_EMAIL_DOMAINS env var. + * When ALLOWED_EMAIL_DOMAINS is set, only emails from those domains can sign in. + * When not set, all email domains are allowed. + */ +function isEmailDomainAllowed(email: string): boolean { + const allowedDomains = env.ALLOWED_EMAIL_DOMAINS; + + // If no domains configured, allow all + if (!allowedDomains || allowedDomains.length === 0) { + return true; + } + + const emailDomain = email.split("@")[1]?.toLowerCase(); + if (!emailDomain) { + return false; + } + + return allowedDomains.includes(emailDomain); +} export const auth = betterAuth({ database: prismaAdapter(prisma, { @@ -40,36 +77,28 @@ export const auth = betterAuth({ }, emailAndPassword: { - enabled: false, // Using magic link instead + enabled: false, // ALWAYS disabled - passwordless only (OAuth + magic link) }, socialProviders: getOAuthProviders(), plugins: [ - // Magic link email authentication - ...(env.NEXT_PUBLIC_SMTP_FROM && !env.SMTP_AUTH_DISABLED + // Admin plugin for user management + admin({ + defaultRole: "user", + }), + + // Magic link plugin for email-based signin (when EMAIL_LOGIN_ENABLED=true) + ...(env.EMAIL_LOGIN_ENABLED ? [ magicLink({ sendMagicLink: async ({ email, url }) => { await sendVerificationEmail({ email, url }); }, - expiresIn: 60 * 5, // 5 minutes - }), - emailOTP({ - sendVerificationOTP: async ({ email, otp }) => { - await sendVerificationEmail({ email, otp }); - }, - otpLength: 6, - expiresIn: 60 * 5, // 5 minutes }), ] : []), - // Admin plugin for user management - admin({ - defaultRole: "user", - }), - // Generic OAuth for custom OIDC providers ...(env.CUSTOM_OAUTH_ISSUER ? [ @@ -117,6 +146,45 @@ export const auth = betterAuth({ trustedProviders: ["github", "google", "facebook", "gitlab", "azure-ad", "keycloak", "custom-oauth"], }, }, + + // Database hooks for domain filtering and workspace creation + databaseHooks: { + user: { + create: { + before: async (user) => { + if (!user.email) { + throw new APIError("BAD_REQUEST", { + message: "Email is required", + }); + } + + if (!isEmailDomainAllowed(user.email)) { + const allowedDomains = env.ALLOWED_EMAIL_DOMAINS?.join(", "); + throw new APIError("FORBIDDEN", { + message: `Sign-in is restricted to authorized email domains: ${allowedDomains}`, + }); + } + + return { data: user }; + }, + after: async (user) => { + // Create initial workspace for new users + const plan = getDefaultWorkspacePlan(user.email); + const workspaceName = user.name ? `${user.name}'s workspace` : "My workspace"; + + await prisma.workspace.create({ + data: { + name: workspaceName, + plan, + members: { + create: [{ role: "ADMIN", userId: user.id }], + }, + }, + }); + }, + }, + }, + }, }); export type Auth = typeof auth; diff --git a/packages/prisma/postgresql/schema.prisma b/packages/prisma/postgresql/schema.prisma index 7851825934..c1a13b69cf 100644 --- a/packages/prisma/postgresql/schema.prisma +++ b/packages/prisma/postgresql/schema.prisma @@ -34,7 +34,7 @@ model User { // Typebot-specific fields lastActivityAt DateTime @default(now()) company String? - onboardingCategories Json @default("{}") + onboardingCategories Json @default("[]") referral String? graphNavigation GraphNavigation? preferredAppAppearance String? diff --git a/packages/scripts/src/createMissingWorkspaces.ts b/packages/scripts/src/createMissingWorkspaces.ts new file mode 100644 index 0000000000..5ffb8238d8 --- /dev/null +++ b/packages/scripts/src/createMissingWorkspaces.ts @@ -0,0 +1,61 @@ +import prisma from "@typebot.io/prisma"; +import { Plan } from "@typebot.io/prisma/enum"; +import { promptAndSetEnvironment } from "./utils"; + +/** + * Creates workspaces for users who don't have any workspace. + * This is needed after migrating from NextAuth to Better Auth, + * as the workspace creation hook was not in place for existing users. + */ +const createMissingWorkspaces = async () => { + await promptAndSetEnvironment("production"); + + // Find all users who don't have any workspace + const usersWithoutWorkspaces = await prisma.user.findMany({ + where: { + workspaces: { + none: {}, + }, + }, + select: { + id: true, + email: true, + name: true, + }, + }); + + console.log( + `Found ${usersWithoutWorkspaces.length} users without workspaces`, + ); + + if (usersWithoutWorkspaces.length === 0) { + console.log("No users need workspace creation"); + return; + } + + const adminEmails = process.env.ADMIN_EMAIL?.split(",") || []; + const defaultPlan = (process.env.DEFAULT_WORKSPACE_PLAN as Plan) || Plan.FREE; + + for (const user of usersWithoutWorkspaces) { + const plan = adminEmails.includes(user.email) ? Plan.UNLIMITED : defaultPlan; + const workspaceName = user.name ? `${user.name}'s workspace` : "My workspace"; + + console.log(`Creating workspace for ${user.email} with plan ${plan}...`); + + await prisma.workspace.create({ + data: { + name: workspaceName, + plan, + members: { + create: [{ role: "ADMIN", userId: user.id }], + }, + }, + }); + + console.log(` ✓ Created workspace "${workspaceName}"`); + } + + console.log("Done!"); +}; + +createMissingWorkspaces(); From 1a93091f609dc1cc4099a699bddd4918aa2adbb2 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Tue, 20 Jan 2026 18:04:45 -0800 Subject: [PATCH 10/11] fix: Remove domain filtering code (reserved for separate PR) The domain filtering functionality (ALLOWED_EMAIL_DOMAINS and EMAIL_LOGIN_ENABLED) will be submitted in a separate follow-up PR. This commit keeps the core Better Auth migration clean and focused. Brian Johnson in useFoundry.ai --- .../src/app/api/auth/[...all]/route.ts | 2 +- .../src/app/api/s3/private/[...key]/route.ts | 3 +- .../src/features/auth/api/updateUserEmail.ts | 4 +- .../features/auth/components/SignInForm.tsx | 16 ++-- .../auth/components/SocialLoginButtons.tsx | 4 +- .../auth/helpers/getAuthenticatedUser.ts | 2 +- .../src/features/user/UserProvider.tsx | 22 +++++- .../builder/src/features/user/server/getMe.ts | 8 +- apps/builder/src/lib/auth/client.ts | 18 ++--- apps/builder/src/lib/auth/config.ts | 76 ++++++------------- .../src/lib/auth/getSessionFromContext.ts | 4 +- apps/builder/src/pages/register.tsx | 2 +- apps/builder/src/pages/signin.tsx | 2 +- apps/docs/openapi/builder.json | 12 +-- .../viewer/src/app/api/auth/[...all]/route.ts | 2 +- apps/viewer/src/lib/auth/config.ts | 4 +- apps/viewer/src/pages/[[...publicId]].tsx | 3 +- bun.lock | 60 +++++++++++++-- packages/env/src/index.ts | 72 ++++++++++++++---- .../scripts/src/createMissingWorkspaces.ts | 8 +- 20 files changed, 203 insertions(+), 121 deletions(-) diff --git a/apps/builder/src/app/api/auth/[...all]/route.ts b/apps/builder/src/app/api/auth/[...all]/route.ts index d441e7ea66..2a41fabc4d 100644 --- a/apps/builder/src/app/api/auth/[...all]/route.ts +++ b/apps/builder/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth/config"; import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/config"; export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/builder/src/app/api/s3/private/[...key]/route.ts b/apps/builder/src/app/api/s3/private/[...key]/route.ts index f2d410ba5c..dafb7489ca 100644 --- a/apps/builder/src/app/api/s3/private/[...key]/route.ts +++ b/apps/builder/src/app/api/s3/private/[...key]/route.ts @@ -63,8 +63,7 @@ export const GET = async ( }); const user = - session?.user ?? - (await authenticateByToken(extractBearerToken(req))); + session?.user ?? (await authenticateByToken(extractBearerToken(req))); if (!user) return new Response("Unauthorized", { status: 401 }); diff --git a/apps/builder/src/features/auth/api/updateUserEmail.ts b/apps/builder/src/features/auth/api/updateUserEmail.ts index 3349d44e50..93746cdbb5 100644 --- a/apps/builder/src/features/auth/api/updateUserEmail.ts +++ b/apps/builder/src/features/auth/api/updateUserEmail.ts @@ -43,7 +43,9 @@ export const updateUserEmail = authenticatedProcedure message: "Invalid verification identifier format", }); } - const newEmail = Buffer.from(identifierParts[1], "base64").toString("utf-8"); + const newEmail = Buffer.from(identifierParts[1], "base64").toString( + "utf-8", + ); if (verification.expiresAt < new Date()) { await deleteVerification(verification.id); diff --git a/apps/builder/src/features/auth/components/SignInForm.tsx b/apps/builder/src/features/auth/components/SignInForm.tsx index 90a811bb1d..70daea8bcb 100644 --- a/apps/builder/src/features/auth/components/SignInForm.tsx +++ b/apps/builder/src/features/auth/components/SignInForm.tsx @@ -13,9 +13,9 @@ import { useQueryState } from "nuqs"; import type { FormEvent } from "react"; import { useEffect, useState } from "react"; import { TextLink } from "@/components/TextLink"; -import { toast } from "@/lib/toast"; import { authClient, useSession } from "@/lib/auth/client"; import type { AvailableProviders } from "@/lib/auth/getAvailableProviders"; +import { toast } from "@/lib/toast"; import { createEmailMagicLink } from "../helpers/createEmailMagicLink"; import { DividerWithText } from "./DividerWithText"; import { SignInError } from "./SignInError"; @@ -27,21 +27,25 @@ type Props = { availableProviders: AvailableProviders; }; -export const SignInForm = ({ defaultEmail, className, availableProviders }: Props) => { +export const SignInForm = ({ + defaultEmail, + className, + availableProviders, +}: Props) => { const { t } = useTranslate(); const router = useRouter(); const [authError, setAuthError] = useQueryState("error"); const [redirectPath] = useQueryState("redirectPath"); const { data: session, isPending } = useSession(); const [authLoading, setAuthLoading] = useState(false); - const [isLoadingProviders, setIsLoadingProviders] = useState(false); + const [_isLoadingProviders, _setIsLoadingProviders] = useState(false); const [emailValue, setEmailValue] = useState(defaultEmail ?? ""); const [isMagicCodeSent, setIsMagicCodeSent] = useState(false); // Check if any provider is configured const hasNoAuthProvider = !Object.entries(availableProviders).some( - ([key, value]) => key !== "customOAuthName" && value + ([key, value]) => key !== "customOAuthName" && value, ); useEffect(() => { @@ -88,7 +92,9 @@ export const SignInForm = ({ defaultEmail, className, availableProviders }: Prop else toast({ description: t("errorMessage"), - details: error.message || "Check server logs to see relevent error message.", + details: + error.message || + "Check server logs to see relevent error message.", }); } else { setIsMagicCodeSent(true); diff --git a/apps/builder/src/features/auth/components/SocialLoginButtons.tsx b/apps/builder/src/features/auth/components/SocialLoginButtons.tsx index 4e4962b18e..f05ee2c20c 100644 --- a/apps/builder/src/features/auth/components/SocialLoginButtons.tsx +++ b/apps/builder/src/features/auth/components/SocialLoginButtons.tsx @@ -27,7 +27,9 @@ export const SocialLoginButtons = ({ availableProviders }: Props) => { query.callbackUrl?.toString() ?? `/typebots?${stringify(omit(query, "error", "callbackUrl"))}`; - const handleSignIn = async (provider: "github" | "google" | "facebook" | "gitlab" | "microsoft") => { + const handleSignIn = async ( + provider: "github" | "google" | "facebook" | "gitlab" | "microsoft", + ) => { setAuthLoading(provider); await authClient.signIn.social({ provider, diff --git a/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts b/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts index e71347457b..c3801630fe 100644 --- a/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts +++ b/apps/builder/src/features/auth/helpers/getAuthenticatedUser.ts @@ -6,7 +6,7 @@ import { auth } from "@/lib/auth/config"; export const getAuthenticatedUser = async ( req: NextApiRequest, - res: NextApiResponse, + _res: NextApiResponse, ): Promise => { const bearerToken = extractBearerToken(req); if (bearerToken) return authenticateByToken(bearerToken); diff --git a/apps/builder/src/features/user/UserProvider.tsx b/apps/builder/src/features/user/UserProvider.tsx index 4645f59d0e..51604016c1 100644 --- a/apps/builder/src/features/user/UserProvider.tsx +++ b/apps/builder/src/features/user/UserProvider.tsx @@ -30,7 +30,11 @@ export const userContext = createContext<{ export const UserProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); const { data: session, isPending } = useSession(); - const status = isPending ? "loading" : session?.user ? "authenticated" : "unauthenticated"; + const status = isPending + ? "loading" + : session?.user + ? "authenticated" + : "unauthenticated"; const [currentWorkspaceId, setCurrentWorkspaceId] = useState(); const { theme, setTheme } = useTheme(); const [localUser, setLocalUser] = useState(); @@ -87,7 +91,13 @@ export const UserProvider = ({ children }: { children: ReactNode }) => { ) { router.replace("/onboarding"); } - }, [router.isReady, router.pathname, status, fullUserData?.termsAcceptedAt, fullUserData?.createdAt]); + }, [ + router.isReady, + router.pathname, + status, + fullUserData?.termsAcceptedAt, + fullUserData?.createdAt, + ]); useEffect(() => { if (!router.isReady) return; @@ -107,7 +117,13 @@ export const UserProvider = ({ children }: { children: ReactNode }) => { { locale: preferredLanguage }, ); } - }, [router.isReady, router.locale, status, isUserLoading, fullUserData?.preferredLanguage]); + }, [ + router.isReady, + router.locale, + status, + isUserLoading, + fullUserData?.preferredLanguage, + ]); const updateUser = async (updates: Partial) => { if (!localUser) return; diff --git a/apps/builder/src/features/user/server/getMe.ts b/apps/builder/src/features/user/server/getMe.ts index 41b8e39b0b..288c410683 100644 --- a/apps/builder/src/features/user/server/getMe.ts +++ b/apps/builder/src/features/user/server/getMe.ts @@ -1,5 +1,5 @@ -import type { ClientUser } from "@typebot.io/user/schemas"; import prisma from "@typebot.io/prisma"; +import type { ClientUser } from "@typebot.io/user/schemas"; import { clientUserSchema } from "@typebot.io/user/schemas"; import { authenticatedProcedure } from "@/helpers/server/trpc"; @@ -34,7 +34,9 @@ export const getMe = authenticatedProcedure // Transform Prisma Json types to proper TypeScript types return { ...fullUser, - displayedInAppNotifications: fullUser.displayedInAppNotifications as ClientUser["displayedInAppNotifications"], - groupTitlesAutoGeneration: fullUser.groupTitlesAutoGeneration as ClientUser["groupTitlesAutoGeneration"], + displayedInAppNotifications: + fullUser.displayedInAppNotifications as ClientUser["displayedInAppNotifications"], + groupTitlesAutoGeneration: + fullUser.groupTitlesAutoGeneration as ClientUser["groupTitlesAutoGeneration"], }; }); diff --git a/apps/builder/src/lib/auth/client.ts b/apps/builder/src/lib/auth/client.ts index 01119ff691..3848dd6351 100644 --- a/apps/builder/src/lib/auth/client.ts +++ b/apps/builder/src/lib/auth/client.ts @@ -1,10 +1,12 @@ "use client"; +import { + adminClient, + emailOTPClient, + genericOAuthClient, + magicLinkClient, +} from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -import { magicLinkClient } from "better-auth/client/plugins"; -import { emailOTPClient } from "better-auth/client/plugins"; -import { adminClient } from "better-auth/client/plugins"; -import { genericOAuthClient } from "better-auth/client/plugins"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "", @@ -16,10 +18,4 @@ export const authClient = createAuthClient({ ], }); -export const { - signIn, - signOut, - signUp, - useSession, - getSession, -} = authClient; +export const { signIn, signOut, signUp, useSession, getSession } = authClient; diff --git a/apps/builder/src/lib/auth/config.ts b/apps/builder/src/lib/auth/config.ts index 27a46dee75..e5e8aaf0dd 100644 --- a/apps/builder/src/lib/auth/config.ts +++ b/apps/builder/src/lib/auth/config.ts @@ -1,12 +1,11 @@ +import { env } from "@typebot.io/env"; +import prisma from "@typebot.io/prisma"; +import { Plan } from "@typebot.io/prisma/enum"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { admin } from "better-auth/plugins/admin"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { magicLink } from "better-auth/plugins/magic-link"; -import { APIError } from "better-auth/api"; -import { env } from "@typebot.io/env"; -import prisma from "@typebot.io/prisma"; -import { Plan } from "@typebot.io/prisma/enum"; import { getOAuthProviders } from "./providers"; import { sendVerificationEmail } from "./send-verification-email"; @@ -25,27 +24,6 @@ function getDefaultWorkspacePlan(userEmail: string): Plan { return Plan.FREE; } -/** - * Validates if an email domain is allowed based on ALLOWED_EMAIL_DOMAINS env var. - * When ALLOWED_EMAIL_DOMAINS is set, only emails from those domains can sign in. - * When not set, all email domains are allowed. - */ -function isEmailDomainAllowed(email: string): boolean { - const allowedDomains = env.ALLOWED_EMAIL_DOMAINS; - - // If no domains configured, allow all - if (!allowedDomains || allowedDomains.length === 0) { - return true; - } - - const emailDomain = email.split("@")[1]?.toLowerCase(); - if (!emailDomain) { - return false; - } - - return allowedDomains.includes(emailDomain); -} - export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", @@ -88,16 +66,12 @@ export const auth = betterAuth({ defaultRole: "user", }), - // Magic link plugin for email-based signin (when EMAIL_LOGIN_ENABLED=true) - ...(env.EMAIL_LOGIN_ENABLED - ? [ - magicLink({ - sendMagicLink: async ({ email, url }) => { - await sendVerificationEmail({ email, url }); - }, - }), - ] - : []), + // Magic link plugin for email-based signin + magicLink({ + sendMagicLink: async ({ email, url }) => { + await sendVerificationEmail({ email, url }); + }, + }), // Generic OAuth for custom OIDC providers ...(env.CUSTOM_OAUTH_ISSUER @@ -143,34 +117,28 @@ export const auth = betterAuth({ account: { accountLinking: { enabled: true, - trustedProviders: ["github", "google", "facebook", "gitlab", "azure-ad", "keycloak", "custom-oauth"], + trustedProviders: [ + "github", + "google", + "facebook", + "gitlab", + "azure-ad", + "keycloak", + "custom-oauth", + ], }, }, - // Database hooks for domain filtering and workspace creation + // Database hooks for workspace creation databaseHooks: { user: { create: { - before: async (user) => { - if (!user.email) { - throw new APIError("BAD_REQUEST", { - message: "Email is required", - }); - } - - if (!isEmailDomainAllowed(user.email)) { - const allowedDomains = env.ALLOWED_EMAIL_DOMAINS?.join(", "); - throw new APIError("FORBIDDEN", { - message: `Sign-in is restricted to authorized email domains: ${allowedDomains}`, - }); - } - - return { data: user }; - }, after: async (user) => { // Create initial workspace for new users const plan = getDefaultWorkspacePlan(user.email); - const workspaceName = user.name ? `${user.name}'s workspace` : "My workspace"; + const workspaceName = user.name + ? `${user.name}'s workspace` + : "My workspace"; await prisma.workspace.create({ data: { diff --git a/apps/builder/src/lib/auth/getSessionFromContext.ts b/apps/builder/src/lib/auth/getSessionFromContext.ts index 3345bb7c30..ff0f2391c6 100644 --- a/apps/builder/src/lib/auth/getSessionFromContext.ts +++ b/apps/builder/src/lib/auth/getSessionFromContext.ts @@ -5,7 +5,9 @@ import { auth } from "./config"; * Get session from GetServerSidePropsContext for pages router * This converts the request to headers that Better Auth can use */ -export async function getSessionFromContext(context: GetServerSidePropsContext) { +export async function getSessionFromContext( + context: GetServerSidePropsContext, +) { const { req } = context; // Build headers from the request diff --git a/apps/builder/src/pages/register.tsx b/apps/builder/src/pages/register.tsx index 0fe0ff2705..bc5d49a139 100644 --- a/apps/builder/src/pages/register.tsx +++ b/apps/builder/src/pages/register.tsx @@ -1,8 +1,8 @@ import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; import { SignInPage } from "@/features/auth/components/SignInPage"; import { - getAvailableProviders, type AvailableProviders, + getAvailableProviders, } from "@/lib/auth/getAvailableProviders"; export const getServerSideProps: GetServerSideProps<{ diff --git a/apps/builder/src/pages/signin.tsx b/apps/builder/src/pages/signin.tsx index 7eb6d70948..7cfe3c2a88 100644 --- a/apps/builder/src/pages/signin.tsx +++ b/apps/builder/src/pages/signin.tsx @@ -1,8 +1,8 @@ import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; import { SignInPage } from "@/features/auth/components/SignInPage"; import { - getAvailableProviders, type AvailableProviders, + getAvailableProviders, } from "@/lib/auth/getAvailableProviders"; export const getServerSideProps: GetServerSideProps<{ diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index 818a9260ad..3c4f2e9b4d 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -3031,12 +3031,10 @@ "type": "object", "properties": { "name": { - "type": "string", - "nullable": true + "type": "string" }, "email": { - "type": "string", - "nullable": true + "type": "string" }, "image": { "type": "string", @@ -13507,8 +13505,7 @@ } }, "name": { - "type": "string", - "nullable": true + "type": "string" }, "image": { "type": "string", @@ -13561,8 +13558,7 @@ "type": "string" }, "name": { - "type": "string", - "nullable": true + "type": "string" }, "email": { "type": "string" diff --git a/apps/viewer/src/app/api/auth/[...all]/route.ts b/apps/viewer/src/app/api/auth/[...all]/route.ts index d441e7ea66..2a41fabc4d 100644 --- a/apps/viewer/src/app/api/auth/[...all]/route.ts +++ b/apps/viewer/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth/config"; import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/config"; export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/viewer/src/lib/auth/config.ts b/apps/viewer/src/lib/auth/config.ts index d5296056dd..a5f944939b 100644 --- a/apps/viewer/src/lib/auth/config.ts +++ b/apps/viewer/src/lib/auth/config.ts @@ -1,7 +1,7 @@ -import { betterAuth } from "better-auth"; -import { prismaAdapter } from "better-auth/adapters/prisma"; import { env } from "@typebot.io/env"; import prisma from "@typebot.io/prisma"; +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; export const auth = betterAuth({ database: prismaAdapter(prisma, { diff --git a/apps/viewer/src/pages/[[...publicId]].tsx b/apps/viewer/src/pages/[[...publicId]].tsx index fe31d03a8a..6a338eda21 100644 --- a/apps/viewer/src/pages/[[...publicId]].tsx +++ b/apps/viewer/src/pages/[[...publicId]].tsx @@ -55,7 +55,8 @@ export const getServerSideProps: GetServerSideProps = async ( if (isMatchingViewerUrl && pathname === "/") { // Early return, will just show a root page // Use BUILDER_URL if set, otherwise fall back to BETTER_AUTH_URL - const builderUrl = env.BUILDER_URL ?? env.BETTER_AUTH_URL ?? "https://app.typebot.io"; + const builderUrl = + env.BUILDER_URL ?? env.BETTER_AUTH_URL ?? "https://app.typebot.io"; return { props: { dashboardUrl: `${builderUrl}/typebots`, diff --git a/bun.lock b/bun.lock index 186abdaa2b..e5b2fe500c 100644 --- a/bun.lock +++ b/bun.lock @@ -1031,6 +1031,7 @@ "@paralleldrive/cuid2": "^2.2.1", "@sentry/nextjs": "^10.32.1", "ioredis": "^5.4.1", + "ky": "^1.2.4", "minio": "^7.1.3", "next": "^15.5.9", "uuidv7": "^1.0.2", @@ -1391,6 +1392,9 @@ "prisma", "@sentry/cli", ], + "overrides": { + "@opentelemetry/winston-transport": "^0.10.0", + }, "packages": { "@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="], @@ -2066,7 +2070,7 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], "@opentelemetry/auto-instrumentations-node": ["@opentelemetry/auto-instrumentations-node@0.66.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0", "@opentelemetry/instrumentation-amqplib": "^0.54.0", "@opentelemetry/instrumentation-aws-lambda": "^0.59.0", "@opentelemetry/instrumentation-aws-sdk": "^0.63.0", "@opentelemetry/instrumentation-bunyan": "^0.53.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.53.0", "@opentelemetry/instrumentation-connect": "^0.51.0", "@opentelemetry/instrumentation-cucumber": "^0.23.0", "@opentelemetry/instrumentation-dataloader": "^0.25.0", "@opentelemetry/instrumentation-dns": "^0.51.0", "@opentelemetry/instrumentation-express": "^0.56.0", "@opentelemetry/instrumentation-fastify": "^0.52.0", "@opentelemetry/instrumentation-fs": "^0.27.0", "@opentelemetry/instrumentation-generic-pool": "^0.51.0", "@opentelemetry/instrumentation-graphql": "^0.55.0", "@opentelemetry/instrumentation-grpc": "^0.207.0", "@opentelemetry/instrumentation-hapi": "^0.54.0", "@opentelemetry/instrumentation-http": "^0.207.0", "@opentelemetry/instrumentation-ioredis": "^0.55.0", "@opentelemetry/instrumentation-kafkajs": "^0.17.0", "@opentelemetry/instrumentation-knex": "^0.52.0", "@opentelemetry/instrumentation-koa": "^0.56.0", "@opentelemetry/instrumentation-lru-memoizer": "^0.52.0", "@opentelemetry/instrumentation-memcached": "^0.51.0", "@opentelemetry/instrumentation-mongodb": "^0.60.0", "@opentelemetry/instrumentation-mongoose": "^0.54.0", "@opentelemetry/instrumentation-mysql": "^0.53.0", "@opentelemetry/instrumentation-mysql2": "^0.54.0", "@opentelemetry/instrumentation-nestjs-core": "^0.54.0", "@opentelemetry/instrumentation-net": "^0.51.0", "@opentelemetry/instrumentation-openai": "^0.5.0", "@opentelemetry/instrumentation-oracledb": "^0.33.0", "@opentelemetry/instrumentation-pg": "^0.60.0", "@opentelemetry/instrumentation-pino": "^0.54.0", "@opentelemetry/instrumentation-redis": "^0.56.0", "@opentelemetry/instrumentation-restify": "^0.53.0", "@opentelemetry/instrumentation-router": "^0.52.0", "@opentelemetry/instrumentation-runtime-node": "^0.21.0", "@opentelemetry/instrumentation-socket.io": "^0.54.0", "@opentelemetry/instrumentation-tedious": "^0.26.0", "@opentelemetry/instrumentation-undici": "^0.18.0", "@opentelemetry/instrumentation-winston": "^0.52.0", "@opentelemetry/resource-detector-alibaba-cloud": "^0.31.10", "@opentelemetry/resource-detector-aws": "^2.7.0", "@opentelemetry/resource-detector-azure": "^0.15.0", "@opentelemetry/resource-detector-container": "^0.7.10", "@opentelemetry/resource-detector-gcp": "^0.42.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-node": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.4.1", "@opentelemetry/core": "^2.0.0" } }, "sha512-WedJs0Qr6qMK/a4qv1X4L0iGAnLma33TEeUpo6Jb8JpK3ZVpm/M3kD+CSwQ6BSJQ4TMymFonGrR+xF7qpDbXUQ=="], @@ -2218,7 +2222,7 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], - "@opentelemetry/winston-transport": ["@opentelemetry/winston-transport@0.19.0", "", { "dependencies": { "@opentelemetry/api-logs": "^0.208.0", "winston-transport": "4.*" } }, "sha512-MeG0fGNcpAhW9J9LiHgAJqIPySzj1xHCx4F+2R0ir4fzvm0ghKQRv6iUm3u1AhyKKJzDBeoHu7W98jJHNw8dnA=="], + "@opentelemetry/winston-transport": ["@opentelemetry/winston-transport@0.10.1", "", { "dependencies": { "@opentelemetry/api-logs": "^0.57.1", "winston-transport": "4.*" } }, "sha512-Lr3YObi3ncWdwfrsxTKwMR9Cah3QYN21G88Ost9c7EnKtFb+H2/I0mNzyk8OqItlI4HgeBWznLlZSwcM74tDKQ=="], "@orpc/client": ["@orpc/client@1.12.3", "", { "dependencies": { "@orpc/shared": "1.12.3", "@orpc/standard-server": "1.12.3", "@orpc/standard-server-fetch": "1.12.3", "@orpc/standard-server-peer": "1.12.3" } }, "sha512-Bk0Z1k6Yiny/sRRtvPRIzPi9w2lIGXadtaTdBI6G0qkhTicKYYABi6bnkchKLN+Ud6zOqD8XwZAe/NuZPz32XQ=="], @@ -7528,12 +7532,58 @@ "@opentelemetry/auto-instrumentations-node/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], + "@opentelemetry/instrumentation-amqplib/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-connect/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-dataloader/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-express/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-fs/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-generic-pool/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-graphql/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-hapi/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-http/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-ioredis/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-kafkajs/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-knex/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-koa/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-lru-memoizer/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-mongodb/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-mongoose/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-mysql/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-mysql2/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-pg/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + "@opentelemetry/instrumentation-pino/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], + "@opentelemetry/instrumentation-redis/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-tedious/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-undici/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + "@opentelemetry/resource-detector-gcp/gcp-metadata/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], "@opentelemetry/resource-detector-gcp/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + "@puppeteer/browsers/tar-fs/tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], "@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -7594,6 +7644,8 @@ "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@sentry/node/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + "@tanstack/directive-functions-plugin/@babel/core/@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="], "@tanstack/directive-functions-plugin/@babel/core/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], @@ -8476,8 +8528,6 @@ "@inngest/realtime/inngest/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-metrics": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig=="], - "@inngest/realtime/inngest/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], - "@inngest/realtime/inngest/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@1.14.2", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw=="], "@inngest/realtime/inngest/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], @@ -9182,8 +9232,6 @@ "@inngest/realtime/inngest/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], - "@inngest/realtime/inngest/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], - "@inngest/realtime/inngest/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="], "@inngest/realtime/inngest/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="], diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 69258b1bb0..aa775259cb 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -132,15 +132,39 @@ const baseEnv = { ), NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: z.string().min(1).optional(), // Auth provider flags (client-side visibility for sign-in UI) - NEXT_PUBLIC_GITHUB_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_GOOGLE_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_FACEBOOK_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_GITLAB_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_AZURE_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_KEYCLOAK_ENABLED: z.string().optional().transform(v => v === "true"), - NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED: z.string().optional().transform(v => v === "true"), + NEXT_PUBLIC_GITHUB_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_GOOGLE_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_FACEBOOK_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_GITLAB_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_AZURE_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_KEYCLOAK_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), + NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), NEXT_PUBLIC_CUSTOM_OAUTH_NAME: z.string().optional(), - NEXT_PUBLIC_EMAIL_ENABLED: z.string().optional().transform(v => v === "true"), + NEXT_PUBLIC_EMAIL_ENABLED: z + .string() + .optional() + .transform((v) => v === "true"), NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: z.coerce.number().optional(), NEXT_PUBLIC_CHAT_API_URL: z.string().url().optional(), NEXT_PUBLIC_VIEWER_404_TITLE: z.string().optional().default("404"), @@ -150,20 +174,36 @@ const baseEnv = { .default("The bot you're looking for doesn't exist"), }, runtimeEnv: { - NEXT_PUBLIC_BETTER_AUTH_URL: getRuntimeVariable("NEXT_PUBLIC_BETTER_AUTH_URL"), + NEXT_PUBLIC_BETTER_AUTH_URL: getRuntimeVariable( + "NEXT_PUBLIC_BETTER_AUTH_URL", + ), NEXT_PUBLIC_VIEWER_URL: getRuntimeVariable("NEXT_PUBLIC_VIEWER_URL"), NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: getRuntimeVariable( "NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID", ), // Auth provider flags - NEXT_PUBLIC_GITHUB_ENABLED: getRuntimeVariable("NEXT_PUBLIC_GITHUB_ENABLED"), - NEXT_PUBLIC_GOOGLE_ENABLED: getRuntimeVariable("NEXT_PUBLIC_GOOGLE_ENABLED"), - NEXT_PUBLIC_FACEBOOK_ENABLED: getRuntimeVariable("NEXT_PUBLIC_FACEBOOK_ENABLED"), - NEXT_PUBLIC_GITLAB_ENABLED: getRuntimeVariable("NEXT_PUBLIC_GITLAB_ENABLED"), + NEXT_PUBLIC_GITHUB_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_GITHUB_ENABLED", + ), + NEXT_PUBLIC_GOOGLE_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_GOOGLE_ENABLED", + ), + NEXT_PUBLIC_FACEBOOK_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_FACEBOOK_ENABLED", + ), + NEXT_PUBLIC_GITLAB_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_GITLAB_ENABLED", + ), NEXT_PUBLIC_AZURE_ENABLED: getRuntimeVariable("NEXT_PUBLIC_AZURE_ENABLED"), - NEXT_PUBLIC_KEYCLOAK_ENABLED: getRuntimeVariable("NEXT_PUBLIC_KEYCLOAK_ENABLED"), - NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED: getRuntimeVariable("NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED"), - NEXT_PUBLIC_CUSTOM_OAUTH_NAME: getRuntimeVariable("NEXT_PUBLIC_CUSTOM_OAUTH_NAME"), + NEXT_PUBLIC_KEYCLOAK_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_KEYCLOAK_ENABLED", + ), + NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED: getRuntimeVariable( + "NEXT_PUBLIC_CUSTOM_OAUTH_ENABLED", + ), + NEXT_PUBLIC_CUSTOM_OAUTH_NAME: getRuntimeVariable( + "NEXT_PUBLIC_CUSTOM_OAUTH_NAME", + ), NEXT_PUBLIC_EMAIL_ENABLED: getRuntimeVariable("NEXT_PUBLIC_EMAIL_ENABLED"), NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: getRuntimeVariable( "NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE", diff --git a/packages/scripts/src/createMissingWorkspaces.ts b/packages/scripts/src/createMissingWorkspaces.ts index 5ffb8238d8..f50c83ebd0 100644 --- a/packages/scripts/src/createMissingWorkspaces.ts +++ b/packages/scripts/src/createMissingWorkspaces.ts @@ -37,8 +37,12 @@ const createMissingWorkspaces = async () => { const defaultPlan = (process.env.DEFAULT_WORKSPACE_PLAN as Plan) || Plan.FREE; for (const user of usersWithoutWorkspaces) { - const plan = adminEmails.includes(user.email) ? Plan.UNLIMITED : defaultPlan; - const workspaceName = user.name ? `${user.name}'s workspace` : "My workspace"; + const plan = adminEmails.includes(user.email) + ? Plan.UNLIMITED + : defaultPlan; + const workspaceName = user.name + ? `${user.name}'s workspace` + : "My workspace"; console.log(`Creating workspace for ${user.email} with plan ${plan}...`); From d22fc1607d0024488a6e1eaabe65d17316c3a333 Mon Sep 17 00:00:00 2001 From: Brian Johnson Date: Thu, 8 Jan 2026 18:37:10 -0800 Subject: [PATCH 11/11] feat: Add domain filtering and passwordless email signin env vars - ALLOWED_EMAIL_DOMAINS: Restrict signin to specific email domains - EMAIL_LOGIN_ENABLED: Enable/disable magic link passwordless signin - emailAndPassword always disabled (no passwords in system) - Domain filtering via databaseHooks on user creation Brian Johnson in useFoundry.ai (cherry picked from commit acda04d207e962c8469773bab6a75adea42e319f) --- apps/builder/src/lib/auth/config.ts | 56 ++++++++++++++++--- .../src/lib/auth/getAvailableProviders.ts | 3 +- packages/env/src/index.ts | 10 ++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/builder/src/lib/auth/config.ts b/apps/builder/src/lib/auth/config.ts index e5e8aaf0dd..185aca97e0 100644 --- a/apps/builder/src/lib/auth/config.ts +++ b/apps/builder/src/lib/auth/config.ts @@ -3,6 +3,7 @@ import prisma from "@typebot.io/prisma"; import { Plan } from "@typebot.io/prisma/enum"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; +import { APIError } from "better-auth/api"; import { admin } from "better-auth/plugins/admin"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { magicLink } from "better-auth/plugins/magic-link"; @@ -24,6 +25,27 @@ function getDefaultWorkspacePlan(userEmail: string): Plan { return Plan.FREE; } +/** + * Validates if an email domain is allowed based on ALLOWED_EMAIL_DOMAINS env var. + * When ALLOWED_EMAIL_DOMAINS is set, only emails from those domains can sign in. + * When not set, all email domains are allowed. + */ +function isEmailDomainAllowed(email: string): boolean { + const allowedDomains = env.ALLOWED_EMAIL_DOMAINS; + + // If no domains configured, allow all + if (!allowedDomains || allowedDomains.length === 0) { + return true; + } + + const emailDomain = email.split("@")[1]?.toLowerCase(); + if (!emailDomain) { + return false; + } + + return allowedDomains.includes(emailDomain); +} + export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", @@ -66,12 +88,16 @@ export const auth = betterAuth({ defaultRole: "user", }), - // Magic link plugin for email-based signin - magicLink({ - sendMagicLink: async ({ email, url }) => { - await sendVerificationEmail({ email, url }); - }, - }), + // Magic link plugin for email-based signin (when EMAIL_LOGIN_ENABLED=true) + ...(env.EMAIL_LOGIN_ENABLED + ? [ + magicLink({ + sendMagicLink: async ({ email, url }) => { + await sendVerificationEmail({ email, url }); + }, + }), + ] + : []), // Generic OAuth for custom OIDC providers ...(env.CUSTOM_OAUTH_ISSUER @@ -129,10 +155,26 @@ export const auth = betterAuth({ }, }, - // Database hooks for workspace creation + // Database hooks for domain filtering and workspace creation databaseHooks: { user: { create: { + before: async (user) => { + if (!user.email) { + throw new APIError("BAD_REQUEST", { + message: "Email is required", + }); + } + + if (!isEmailDomainAllowed(user.email)) { + const allowedDomains = env.ALLOWED_EMAIL_DOMAINS?.join(", "); + throw new APIError("FORBIDDEN", { + message: `Sign-in is restricted to authorized email domains: ${allowedDomains}`, + }); + } + + return { data: user }; + }, after: async (user) => { // Create initial workspace for new users const plan = getDefaultWorkspacePlan(user.email); diff --git a/apps/builder/src/lib/auth/getAvailableProviders.ts b/apps/builder/src/lib/auth/getAvailableProviders.ts index 99c27320a6..78be3462e4 100644 --- a/apps/builder/src/lib/auth/getAvailableProviders.ts +++ b/apps/builder/src/lib/auth/getAvailableProviders.ts @@ -40,6 +40,7 @@ export function getAvailableProviders(): AvailableProviders { env.CUSTOM_OAUTH_ISSUER ), customOAuthName: env.CUSTOM_OAUTH_NAME, - email: !!(env.NEXT_PUBLIC_SMTP_FROM && !env.SMTP_AUTH_DISABLED), + // Email/magic link controlled by EMAIL_LOGIN_ENABLED env var + email: env.EMAIL_LOGIN_ENABLED, }; } diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index aa775259cb..857015bd36 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -78,6 +78,16 @@ const baseEnv = { // App name for branding in emails APP_NAME: z.string().optional().default("Typebot"), DISABLE_SIGNUP: boolean.optional().default("false"), + // Email domain filtering: comma-separated list of allowed domains + // When set, only emails from these domains can sign in + // When not set, all email domains are allowed + ALLOWED_EMAIL_DOMAINS: z + .string() + .optional() + .transform((val) => val?.split(",").map((d) => d.trim().toLowerCase())), + // Enable/disable email-based magic link signin + // When false or not set, only OAuth providers are available + EMAIL_LOGIN_ENABLED: boolean.optional().default("false"), ADMIN_EMAIL: z .string() .min(1)