diff --git a/.env.docker b/.env.docker index 8700d35a5d..33ecc2c051 100644 --- a/.env.docker +++ b/.env.docker @@ -4,20 +4,40 @@ ############ -# Basics # +# BASICS # ############ +NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 + +############## +# DATABASE # +############## + +DATABASE_URL='postgresql://postgres:postgres@postgres:5432/formbricks?schema=public' + +# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy +# Cold boots will be faster and you'll be able to scale your DB independently of your app. +# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy +# PRISMA_GENERATE_DATAPROXY=true +PRISMA_GENERATE_DATAPROXY= + +############### +# NEXT AUTH # +############### + +# @see: https://next-auth.js.org/configuration/options#nextauth_secret +# You can use: `openssl rand -base64 32` to generate one NEXTAUTH_SECRET=RANDOM_STRING +# Set this to your public-facing URL, e.g., https://example.com +# You do not need the NEXTAUTH_URL environment variable in Vercel. NEXTAUTH_URL=http://localhost:3000 # If you encounter NEXT_AUTH URL problems this should always be localhost:3000 (or whatever port your app is running on) # NEXTAUTH_URL_INTERNAL=http://localhost:3000 -DATABASE_URL='postgresql://postgres:postgres@postgres:5432/postgres?schema=public' - ################ -# Mail Setup # +# MAIL SETUP # ################ # Necessary if email verification and password reset are enabled. diff --git a/.env.example b/.env.example index 7a36a72bc9..34a9507bb5 100644 --- a/.env.example +++ b/.env.example @@ -4,18 +4,16 @@ ############ -# Basics # +# BASICS # ############ -NEXTAUTH_SECRET=RANDOM_STRING +NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 -# Set this to your public-facing URL, e.g., https://example.com -NEXTAUTH_URL=http://localhost:3000 +############## +# DATABASE # +############## -# If you encounter NEXT_AUTH URL problems this should always be localhost:3000 (or whatever port your app is running on) -# NEXTAUTH_URL_INTERNAL=http://localhost:3000 - -DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres?schema=public' +DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy # Cold boots will be faster and you'll be able to scale your DB independently of your app. @@ -23,8 +21,23 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres?schema=publ # PRISMA_GENERATE_DATAPROXY=true PRISMA_GENERATE_DATAPROXY= +############### +# NEXT AUTH # +############### + +# @see: https://next-auth.js.org/configuration/options#nextauth_secret +# You can use: `openssl rand -base64 32` to generate one +NEXTAUTH_SECRET=RANDOM_STRING + +# Set this to your public-facing URL, e.g., https://example.com +# You do not need the NEXTAUTH_URL environment variable in Vercel. +NEXTAUTH_URL=http://localhost:3000 + +# If you encounter NEXT_AUTH URL problems this should always be localhost:3000 (or whatever port your app is running on) +# NEXTAUTH_URL_INTERNAL=http://localhost:3000 + ################ -# Mail Setup # +# MAIL SETUP # ################ # Necessary if email verification and password reset are enabled. diff --git a/apps/web/app/HomeRedirect.tsx b/apps/web/app/HomeRedirect.tsx deleted file mode 100644 index 299d98db1f..0000000000 --- a/apps/web/app/HomeRedirect.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import LoadingSpinner from "@/components/shared/LoadingSpinner"; -import { fetcher } from "@formbricks/lib/fetcher"; -import type { Session } from "next-auth"; -import { signOut } from "next-auth/react"; -import { redirect } from "next/navigation"; -import { useEffect } from "react"; -import useSWR from "swr"; - -interface HomeRedirectProps { - session: Session; -} - -export function HomeRedirect({ session }: HomeRedirectProps) { - const { data, error } = useSWR(`/api/v1/environments/find-first`, fetcher); - - useEffect(() => { - if (session) { - if (!session.user?.onboardingDisplayed) { - return redirect(`/onboarding`); - } - - if (data && !error) { - return redirect(`/environments/${data.id}`); - } else if (error) { - console.error(error); - } - } else { - return redirect(`/auth/login`); - } - }, [data, error, session]); - - if (error) { - setTimeout(() => { - signOut(); - }, 3000); - return
There was an error with your current Session. You are getting redirected to the login.
; - } - - return ( -
- -
- ); -} diff --git a/apps/web/pages/api/auth/[...nextauth].ts b/apps/web/app/api/auth/[...nextauth]/authOptions.ts similarity index 98% rename from apps/web/pages/api/auth/[...nextauth].ts rename to apps/web/app/api/auth/[...nextauth]/authOptions.ts index 21c4b50a94..2741b21ee7 100644 --- a/apps/web/pages/api/auth/[...nextauth].ts +++ b/apps/web/app/api/auth/[...nextauth]/authOptions.ts @@ -2,9 +2,7 @@ import { verifyPassword } from "@/lib/auth"; import { verifyToken } from "@/lib/jwt"; import { prisma } from "@formbricks/database"; import { IdentityProvider } from "@prisma/client"; -import { NextApiRequest, NextApiResponse } from "next"; import type { NextAuthOptions } from "next-auth"; -import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import GitHubProvider from "next-auth/providers/github"; @@ -350,7 +348,3 @@ export const authOptions: NextAuthOptions = { error: "/auth/login", // Error code passed in query string as ?error= }, }; - -export default async function auth(req: NextApiRequest, res: NextApiResponse) { - return await NextAuth(req, res, authOptions); -} diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..f1cc59ec9c --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "./authOptions"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/v1/memberships/route.ts b/apps/web/app/api/v1/memberships/route.ts new file mode 100644 index 0000000000..ed2ec86e8f --- /dev/null +++ b/apps/web/app/api/v1/memberships/route.ts @@ -0,0 +1,21 @@ +import { getSessionUser } from "@/lib/api/apiHelper"; +import { prisma } from "@formbricks/database"; +import { NextResponse } from "next/server"; + +export async function GET() { + const sessionUser = await getSessionUser(); + if (!sessionUser) { + return new Response("Not authenticated", { + status: 401, + }); + } + + // get memberships + const memberships = await prisma.membership.findMany({ + where: { + userId: sessionUser.id, + }, + }); + + return NextResponse.json(memberships); +} diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts new file mode 100644 index 0000000000..1695486259 --- /dev/null +++ b/apps/web/app/api/v1/users/me/route.ts @@ -0,0 +1,39 @@ +import { getSessionUser } from "@/lib/api/apiHelper"; +import { prisma } from "@formbricks/database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET() { + const sessionUser = await getSessionUser(); + if (!sessionUser) { + return new Response("Not authenticated", { + status: 401, + }); + } + + const user = await prisma.user.findUnique({ + where: { + email: sessionUser.email, + }, + }); + + return NextResponse.json(user); +} + +export async function PUT(request: NextRequest) { + const sessionUser = await getSessionUser(); + if (!sessionUser) { + return new Response("Not authenticated", { + status: 401, + }); + } + const body = await request.json(); + + const user = await prisma.user.update({ + where: { + email: sessionUser.email, + }, + data: body, + }); + + return NextResponse.json(user); +} diff --git a/apps/web/app/environments/[environmentId]/layout.tsx b/apps/web/app/environments/[environmentId]/layout.tsx index 75661d3e3e..a8ddc748d8 100644 --- a/apps/web/app/environments/[environmentId]/layout.tsx +++ b/apps/web/app/environments/[environmentId]/layout.tsx @@ -2,7 +2,7 @@ import EnvironmentsNavbar from "@/app/environments/[environmentId]/EnvironmentsN import ToasterClient from "@/components/ToasterClient"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { authOptions } from "pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import PosthogIdentify from "./PosthogIdentify"; import FormbricksClient from "./FormbricksClient"; import { PosthogClientWrapper } from "../../PosthogClientWrapper"; diff --git a/apps/web/app/environments/[environmentId]/settings/billing/page.tsx b/apps/web/app/environments/[environmentId]/settings/billing/page.tsx index 994145e537..ab429915a7 100644 --- a/apps/web/app/environments/[environmentId]/settings/billing/page.tsx +++ b/apps/web/app/environments/[environmentId]/settings/billing/page.tsx @@ -3,7 +3,7 @@ import SettingsTitle from "../SettingsTitle"; import { Button } from "@formbricks/ui"; import PricingTable from "./PricingTable"; import { getServerSession } from "next-auth"; -import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; const proPlan = false; diff --git a/apps/web/app/environments/[environmentId]/settings/profile/page.tsx b/apps/web/app/environments/[environmentId]/settings/profile/page.tsx index 76d0443e7e..cc0498bfe0 100644 --- a/apps/web/app/environments/[environmentId]/settings/profile/page.tsx +++ b/apps/web/app/environments/[environmentId]/settings/profile/page.tsx @@ -2,7 +2,7 @@ import SettingsCard from "../SettingsCard"; import SettingsTitle from "../SettingsTitle"; import { getServerSession } from "next-auth"; import { EditName, EditAvatar } from "./editProfile"; -import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; export default async function ProfileSettingsPage() { const session = await getServerSession(authOptions); diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000000..57d5ca76ee --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,26 @@ +"use client"; // Error components must be Client components + +import { Button, ErrorComponent } from "@/../../packages/ui"; +import { useEffect } from "react"; + +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/invite/page.tsx b/apps/web/app/invite/page.tsx index f3468cda7e..9b3f7816ca 100644 --- a/apps/web/app/invite/page.tsx +++ b/apps/web/app/invite/page.tsx @@ -1,6 +1,6 @@ import { sendInviteAcceptedEmail } from "@/lib/email"; import { verifyInviteToken } from "@/lib/jwt"; -import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth"; import { env } from "process"; import { prisma } from "@formbricks/database"; diff --git a/apps/web/app/onboarding/Onboarding.tsx b/apps/web/app/onboarding/Onboarding.tsx index 6ada87b501..e09d6ed87d 100644 --- a/apps/web/app/onboarding/Onboarding.tsx +++ b/apps/web/app/onboarding/Onboarding.tsx @@ -23,7 +23,7 @@ interface OnboardingProps { } export default function Onboarding({ session }: OnboardingProps) { - const { data, error } = useSWR(`/api/v1/environments/find-first`, fetcher); + const { data: environment, error } = useSWR(`/api/v1/environments/find-first`, fetcher); const { profile } = useProfile(); const { triggerProfileMutate } = useProfileMutation(); const [currentStep, setCurrentStep] = useState(1); @@ -62,13 +62,13 @@ export default function Onboarding({ session }: OnboardingProps) { try { const updatedProfile = { ...profile, onboardingDisplayed: true }; await triggerProfileMutate(updatedProfile); - if (data) { - await router.push(`/environments/${data.id}/surveys`); + if (environment) { + router.push(`/environments/${environment.id}/surveys`); + return; } } catch (e) { toast.error("An error occured saving your settings."); console.error(e); - } finally { } }; @@ -92,7 +92,7 @@ export default function Onboarding({ session }: OnboardingProps) { {currentStep === 1 && } {currentStep === 2 && } {currentStep === 3 && } - {currentStep === 4 && } + {currentStep === 4 && } ); diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx index 5db94da028..2f99bf5510 100644 --- a/apps/web/app/onboarding/page.tsx +++ b/apps/web/app/onboarding/page.tsx @@ -1,6 +1,6 @@ import { getServerSession } from "next-auth"; import Onboarding from "./Onboarding"; -import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; export default async function OnboardingPage() { const session = await getServerSession(authOptions); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index d92c64d3f0..79f9f6b737 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,21 +1,41 @@ -import LoadingSpinner from "@/components/shared/LoadingSpinner"; +import { WEBAPP_URL } from "@/../../packages/lib/constants"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { authOptions } from "pages/api/auth/[...nextauth]"; -import { HomeRedirect } from "./HomeRedirect"; -import { PosthogClientWrapper } from "./PosthogClientWrapper"; + +async function getEnvironment() { + const cookie = headers().get("cookie") || ""; + const res = await fetch(`${WEBAPP_URL}/api/v1/environments/find-first`, { + headers: { + cookie, + }, + }); + + if (!res.ok) { + throw new Error("Failed to fetch data"); + } + + return res.json(); +} export default async function Home() { - const session = await getServerSession(authOptions); + const session: Session | null = await getServerSession(authOptions); + if (!session) { redirect("/auth/login"); } - return ( - -
- - -
-
- ); + + if (session?.user && !session?.user?.onboardingDisplayed) { + return redirect(`/onboarding`); + } + + const environment = await getEnvironment(); + + if (!environment) { + throw Error("No environment found for user"); + } + + return redirect(`/environments/${environment.id}`); } diff --git a/apps/web/lib/api/apiHelper.ts b/apps/web/lib/api/apiHelper.ts index 7bbde00a0e..ae089cc2fc 100644 --- a/apps/web/lib/api/apiHelper.ts +++ b/apps/web/lib/api/apiHelper.ts @@ -1,7 +1,8 @@ -import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { prisma } from "@formbricks/database"; import { createHash } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; +import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); @@ -115,8 +116,13 @@ export const hasTeamAccess = async (user, teamId) => { return false; }; -export const getSessionUser = async (req: NextApiRequest, res: NextApiResponse) => { +export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => { // check for session (browser usage) - let session: any = await getServerSession(req, res, authOptions); + let session: Session | null; + if (req && res) { + session = await getServerSession(req, res, authOptions); + } else { + session = await getServerSession(authOptions); + } if (session && "user" in session) return session.user; }; diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts index b686c8f5be..e9d28199dc 100644 --- a/apps/web/lib/email.ts +++ b/apps/web/lib/email.ts @@ -1,3 +1,4 @@ +import { WEBAPP_URL } from "@formbricks/lib/constants"; import { withEmailTemplate } from "./email-template"; import { createInviteToken, createToken } from "./jwt"; const nodemailer = require("nodemailer"); @@ -32,10 +33,10 @@ export const sendVerificationEmail = async (user) => { const token = createToken(user.id, user.email, { expiresIn: "1d", }); - const verifyLink = `${process.env.NEXTAUTH_URL}/auth/verify?token=${encodeURIComponent(token)}`; - const verificationRequestLink = `${ - process.env.NEXTAUTH_URL - }/auth/verification-requested?email=${encodeURIComponent(user.email)}`; + const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`; + const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?email=${encodeURIComponent( + user.email + )}`; await sendEmail({ to: user.email, subject: "Welcome to Formbricks 🤍", @@ -54,9 +55,7 @@ export const sendForgotPasswordEmail = async (user) => { const token = createToken(user.id, user.email, { expiresIn: "1d", }); - const verifyLink = `${process.env.NEXTAUTH_URL}/auth/forgot-password/reset?token=${encodeURIComponent( - token - )}`; + const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`; await sendEmail({ to: user.email, subject: "Reset your Formbricks password", @@ -84,8 +83,7 @@ export const sendInviteMemberEmail = async (inviteId, inviterName, inviteeName, const token = createInviteToken(inviteId, email, { expiresIn: "7d", }); - // const verifyLink = `${process.env.NEXTAUTH_URL}/api/v1/invite?token=${encodeURIComponent(token)}`; - const verifyLink = `${process.env.NEXTAUTH_URL}/invite?token=${encodeURIComponent(token)}`; + const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`; await sendEmail({ to: email, diff --git a/apps/web/pages/api/v1/memberships/index.ts b/apps/web/pages/api/v1/memberships/index.ts deleted file mode 100644 index 74c43d3b08..0000000000 --- a/apps/web/pages/api/v1/memberships/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getSessionUser } from "@/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const user: any = await getSessionUser(req, res); - if (!user) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // GET - if (req.method === "GET") { - // get memberships - const memberships = await prisma.membership.findMany({ - where: { - userId: user.id, - }, - include: { - team: { - select: { - id: true, - name: true, - products: { - select: { - id: true, - name: true, - environments: { - select: { - id: true, - type: true, - }, - }, - }, - }, - }, - }, - }, - }); - return res.json(memberships); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/users/me/index.ts b/apps/web/pages/api/v1/users/me/index.ts deleted file mode 100644 index 39c9be42b4..0000000000 --- a/apps/web/pages/api/v1/users/me/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { getSessionUser } from "@/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const session = await getSessionUser(req, res); - if (!session) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // GET /api/users/me - // Get the current user - if (req.method === "GET") { - const user = await prisma.user.findUnique({ - where: { - email: session.email, - }, - /* select: { - id: true, - createdAt: true, - updatedAt: true, - email: true, - name: true, - identityProvider: true, - }, */ - }); - return res.json(user); - } // GET /api/users/me - // Get the current user - else if (req.method === "PUT") { - const user = await prisma.user.update({ - where: { - email: session.email, - }, - data: req.body, - }); - return res.json(user); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index ad2ea2c39f..f204f268fe 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1,2 +1,15 @@ export const RESPONSES_LIMIT_FREE = 100; export const IS_FORMBRICKS_CLOUD = process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1"; + +// URLs +const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : ""; +const RAILWAY_STATIC_URL = process.env.RAILWAY_STATIC_URL ? `https://${process.env.RAILWAY_STATIC_URL}` : ""; +const HEROKU_URL = process.env.HEROKU_APP_NAME ? `https://${process.env.HEROKU_APP_NAME}.herokuapp.com` : ""; +const RENDER_URL = process.env.RENDER_EXTERNAL_URL ? `https://${process.env.RENDER_EXTERNAL_URL}` : ""; +export const WEBAPP_URL = + process.env.NEXT_PUBLIC_WEBAPP_URL || + VERCEL_URL || + RAILWAY_STATIC_URL || + HEROKU_URL || + RENDER_URL || + "http://localhost:3000"; diff --git a/packages/lib/telemetry.ts b/packages/lib/telemetry.ts index 5c3d02118f..e97c4a0ff1 100644 --- a/packages/lib/telemetry.ts +++ b/packages/lib/telemetry.ts @@ -7,7 +7,6 @@ export const captureTelemetry = async (eventName: string, properties = {}) => { if ( process.env.TELEMETRY_DISABLED !== "1" && process.env.NODE_ENV === "production" && - process.env.NEXTAUTH_URL !== "http://localhost:3000" && process.env.INSTANCE_ID ) { try { diff --git a/turbo.json b/turbo.json index 6cef9cabd2..f4d4985b17 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,7 @@ "FORMBRICKS_LEGACY_HOST", "GITHUB_ID", "GITHUB_SECRET", + "HEROKU_APP_NAME", "INSTANCE_ID", "MAIL_FROM", "NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED", @@ -38,12 +39,16 @@ "NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID", "NEXT_PUBLIC_FORMBRICKS_URL", "NEXT_PUBLIC_IMPRINT_URL", + "NEXT_PUBLIC_VERCEL_URL", "NODE_ENV", "NEXT_PUBLIC_POSTHOG_API_HOST", "NEXT_PUBLIC_POSTHOG_API_KEY", "NEXT_PUBLIC_FORMBRICKS_COM_API_HOST", "NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID", "NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID", + "NEXT_PUBLIC_WEBAPP_URL", + "RAILWAY_STATIC_URL", + "RENDER_EXTERNAL_URL", "SENTRY_DSN", "TELEMETRY_DISABLED", "VERCEL_URL"