Add support for Next.js Server-Components (#272)

* Add new env variable NEXT_PUBLIC_WEBAPP_URL that is used server-side for the current address

* Move Next-Auth to App-Directory

* Move Membership-API & User-API to App-Directory

* Update env-examples with new structure
This commit is contained in:
Matti Nannt
2023-05-03 15:17:23 +02:00
committed by GitHub
parent 056ddff709
commit 04f536b7c6
22 changed files with 215 additions and 193 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 <div>There was an error with your current Session. You are getting redirected to the login.</div>;
}
return (
<div>
<LoadingSpinner />
</div>
);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "./authOptions";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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);

26
apps/web/app/error.tsx Normal file
View File

@@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center">
<ErrorComponent />
<Button
variant="secondary"
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
className="mt-2">
Try again
</Button>
</div>
);
}

View File

@@ -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";

View File

@@ -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 && <Greeting next={next} skip={skip} name={profile.name} session={session} />}
{currentStep === 2 && <Role next={next} skip={skip} />}
{currentStep === 3 && <Objective next={next} skip={skip} />}
{currentStep === 4 && <Product done={done} environmentId={data.id} isLoading={isLoading} />}
{currentStep === 4 && <Product done={done} environmentId={environment.id} isLoading={isLoading} />}
</div>
</div>
);

View File

@@ -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);

View File

@@ -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 (
<PosthogClientWrapper>
<div>
<HomeRedirect session={session} />
<LoadingSpinner />
</div>
</PosthogClientWrapper>
);
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}`);
}

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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.`);
}
}

View File

@@ -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.`);
}
}

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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"