diff --git a/apps/web/declarations/environment.d.ts b/apps/web/declarations/environment.d.ts index 8150b9e90..99acfe476 100644 --- a/apps/web/declarations/environment.d.ts +++ b/apps/web/declarations/environment.d.ts @@ -10,9 +10,9 @@ declare global { */ NODE_ENV: "development" | "production"; /** - * Can be "false" or a relative path eg. "/new" + * Set to "true" to take users straight to app instead of landing page */ - LANDING_PAGE: string; + DISABLE_LANDING_PAGE?: string; /** * Must be 32 characters long */ @@ -57,6 +57,17 @@ declare global { * Port number of the SMTP server */ SMTP_PORT: string; + /** + * Comma separated list of email addresses that are allowed to register and login. + * If not set, all emails are allowed. Wildcard characters are supported. + * + * Example: "user@example.com, *@example.com, *@*.example.com" + */ + ALLOWED_EMAILS?: string; + /** + * "true" to require authentication for creating new polls and accessing admin pages + */ + AUTH_REQUIRED?: string; } } } diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f1e63cc19..681057b7a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -40,7 +40,8 @@ const nextConfig = { return [ { source: "/", - destination: "/home", + destination: + process.env.DISABLE_LANDING_PAGE === "true" ? "/new" : "/home", }, ]; }, diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 1a95009a2..3d2c50f57 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -39,6 +39,7 @@ "editDetails": "Edit details", "editOptions": "Edit options", "email": "Email", + "emailNotAllowed": "This email is not allowed.", "emailPlaceholder": "jessie.smith@email.com", "endingGuestSessionNotice": "Once a guest session ends it cannot be resumed. You will not be able to edit any votes or comments you've made with this session.", "endSession": "End session", diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx index 2977a7279..a7d395786 100644 --- a/apps/web/src/components/auth/login-form.tsx +++ b/apps/web/src/components/auth/login-form.tsx @@ -182,12 +182,16 @@ export const RegisterForm: React.FunctionComponent<{ }); if (!res.ok) { - switch (res.code) { + switch (res.reason) { case "userAlreadyExists": setError("email", { message: t("userAlreadyExists"), }); break; + case "emailNotAllowed": + setError("email", { + message: t("emailNotAllowed"), + }); } } else { setToken(res.token); @@ -308,7 +312,22 @@ export const LoginForm: React.FunctionComponent<{ email: values.email, }); - setToken(res.token); + if (res.ok) { + setToken(res.token); + } else { + switch (res.reason) { + case "emailNotAllowed": + setError("email", { + message: t("emailNotAllowed"), + }); + break; + case "userNotFound": + setError("email", { + message: t("userNotFound"), + }); + break; + } + } }} onChange={() => setToken(undefined)} email={getValues("email")} @@ -323,13 +342,21 @@ export const LoginForm: React.FunctionComponent<{ email: data.email, }); - if (!res.token) { - setError("email", { - type: "not_found", - message: t("userNotFound"), - }); - } else { + if (res.ok) { setToken(res.token); + } else { + switch (res.reason) { + case "emailNotAllowed": + setError("email", { + message: t("emailNotAllowed"), + }); + break; + case "userNotFound": + setError("email", { + message: t("userNotFound"), + }); + break; + } } })} > diff --git a/apps/web/src/components/layouts/standard-layout/user-dropdown.tsx b/apps/web/src/components/layouts/standard-layout/user-dropdown.tsx index de4be8c53..fe7bbf6c2 100644 --- a/apps/web/src/components/layouts/standard-layout/user-dropdown.tsx +++ b/apps/web/src/components/layouts/standard-layout/user-dropdown.tsx @@ -79,11 +79,11 @@ export const UserDropdown: React.FunctionComponent = ({ onClick={openLoginModal} /> ) : null} - { - if (user?.isGuest) { + {user.isGuest ? ( + { modalContext.render({ title: t("app:areYouSure"), description: t("app:endingGuestSessionNotice"), @@ -95,11 +95,11 @@ export const UserDropdown: React.FunctionComponent = ({ okText: t("app:endSession"), cancelText: t("app:cancel"), }); - } else { - logout(); - } - }} - /> + }} + /> + ) : ( + + )} ); }; diff --git a/apps/web/src/pages/admin/[urlId].tsx b/apps/web/src/pages/admin/[urlId].tsx index 52557ce6f..94802999a 100644 --- a/apps/web/src/pages/admin/[urlId].tsx +++ b/apps/web/src/pages/admin/[urlId].tsx @@ -7,7 +7,7 @@ import { getStandardLayout } from "@/components/layouts/standard-layout"; import { ParticipantsProvider } from "@/components/participants-provider"; import { Poll } from "@/components/poll"; import { PollContextProvider } from "@/components/poll-context"; -import { withSessionSsr } from "@/utils/auth"; +import { withAuthIfRequired, withSessionSsr } from "@/utils/auth"; import { trpc } from "@/utils/trpc"; import { withPageTranslations } from "@/utils/with-page-translations"; @@ -51,6 +51,7 @@ Page.getLayout = getStandardLayout; export const getServerSideProps: GetServerSideProps = withSessionSsr( [ + withAuthIfRequired, withPageTranslations(["common", "app", "errors"]), async (ctx) => { return { diff --git a/apps/web/src/pages/demo.tsx b/apps/web/src/pages/demo.tsx index f9e4c3a32..d86fdb3e0 100644 --- a/apps/web/src/pages/demo.tsx +++ b/apps/web/src/pages/demo.tsx @@ -7,7 +7,7 @@ import { useMount } from "react-use"; import FullPageLoader from "../components/full-page-loader"; import { withSession } from "../components/user-provider"; -import { withSessionSsr } from "../utils/auth"; +import { withAuthIfRequired, withSessionSsr } from "../utils/auth"; import { trpc } from "../utils/trpc"; import { withPageTranslations } from "../utils/with-page-translations"; @@ -26,8 +26,9 @@ const Demo: NextPage = () => { return {t("creatingDemo")}; }; -export const getServerSideProps = withSessionSsr( +export const getServerSideProps = withSessionSsr([ + withAuthIfRequired, withPageTranslations(["common", "app"]), -); +]); export default withSession(Demo); diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index b2859eb98..ee7043acb 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -1,37 +1,20 @@ import { GetServerSideProps } from "next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import Home from "@/components/home"; +import { composeGetServerSideProps } from "@/utils/auth"; +import { withPageTranslations } from "@/utils/with-page-translations"; export default function Page() { return ; } -export const getServerSideProps: GetServerSideProps = async ({ - locale = "en", -}) => { - if (process.env.LANDING_PAGE) { - if (process.env.LANDING_PAGE === "false") { - return { - redirect: { - destination: "/new", - permanent: false, - }, - }; +export const getServerSideProps: GetServerSideProps = composeGetServerSideProps( + async () => { + // TODO (Luke Vella) [2023-03-14]: Remove this once we split the app from the landing page + if (process.env.DISABLE_LANDING_PAGE === "true") { + return { notFound: true }; } - // if starts with /, it's a relative path - if (process.env.LANDING_PAGE.startsWith("/")) { - return { - redirect: { - destination: process.env.LANDING_PAGE, - permanent: false, - }, - }; - } - } - return { - props: { - ...(await serverSideTranslations(locale, ["common", "homepage"])), - }, - }; -}; + return { props: {} }; + }, + withPageTranslations(["common", "homepage"]), +); diff --git a/apps/web/src/pages/new.tsx b/apps/web/src/pages/new.tsx index 1e9f7b22d..bb5d5ef0e 100644 --- a/apps/web/src/pages/new.tsx +++ b/apps/web/src/pages/new.tsx @@ -6,7 +6,7 @@ import CreatePoll from "@/components/create-poll"; import StandardLayout from "../components/layouts/standard-layout"; import { NextPageWithLayout } from "../types"; -import { withSessionSsr } from "../utils/auth"; +import { withAuthIfRequired, withSessionSsr } from "../utils/auth"; import { withPageTranslations } from "../utils/with-page-translations"; const Page: NextPageWithLayout = () => { @@ -28,6 +28,7 @@ Page.getLayout = function getLayout(page) { export default Page; -export const getServerSideProps: GetServerSideProps = withSessionSsr( +export const getServerSideProps: GetServerSideProps = withSessionSsr([ + withAuthIfRequired, withPageTranslations(["common", "app"]), -); +]); diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 05cb67c97..b92280070 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,4 +1,4 @@ -import { withSessionSsr } from "@/utils/auth"; +import { withAuth, withSessionSsr } from "@/utils/auth"; import { getStandardLayout } from "../components/layouts/standard-layout"; import { Profile } from "../components/profile"; @@ -11,16 +11,9 @@ const Page: NextPageWithLayout = () => { Page.getLayout = getStandardLayout; -export const getServerSideProps = withSessionSsr(async (ctx) => { - if (ctx.req.session.user.isGuest !== false) { - return { - redirect: { - destination: "/login", - }, - props: {}, - }; - } - return withPageTranslations(["common", "app"])(ctx); -}); +export const getServerSideProps = withSessionSsr([ + withAuth, + withPageTranslations(["common", "app"]), +]); export default Page; diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts index 7131f90fd..1133f164c 100644 --- a/apps/web/src/server/routers/auth.ts +++ b/apps/web/src/server/routers/auth.ts @@ -15,6 +15,22 @@ import { import { generateOtp } from "../../utils/nanoid"; import { publicProcedure, router } from "../trpc"; +const isEmailBlocked = (email: string) => { + if (process.env.ALLOWED_EMAILS) { + const allowedEmails = process.env.ALLOWED_EMAILS.split(","); + // Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS + const isAllowed = allowedEmails.some((allowedEmail) => { + const regex = new RegExp(allowedEmail.trim().replace("*", ".*"), "i"); + return regex.test(email); + }); + + if (!isAllowed) { + return true; + } + } + return false; +}; + export const auth = router({ requestRegistration: publicProcedure .input( @@ -27,8 +43,13 @@ export const auth = router({ async ({ input, }): Promise< - { ok: true; token: string } | { ok: false; code: "userAlreadyExists" } + | { ok: true; token: string } + | { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" } > => { + if (isEmailBlocked(input.email)) { + return { ok: false, reason: "emailNotAllowed" }; + } + const user = await prisma.user.findUnique({ select: { id: true, @@ -39,7 +60,7 @@ export const auth = router({ }); if (user) { - return { ok: false, code: "userAlreadyExists" }; + return { ok: false, reason: "userAlreadyExists" }; } const code = await generateOtp(); @@ -108,36 +129,47 @@ export const auth = router({ email: z.string(), }), ) - .mutation(async ({ input }): Promise<{ token?: string }> => { - const user = await prisma.user.findUnique({ - where: { - email: input.email, - }, - }); + .mutation( + async ({ + input, + }): Promise< + | { ok: true; token: string } + | { ok: false; reason: "emailNotAllowed" | "userNotFound" } + > => { + if (isEmailBlocked(input.email)) { + return { ok: false, reason: "emailNotAllowed" }; + } - if (!user) { - return { token: undefined }; - } + const user = await prisma.user.findUnique({ + where: { + email: input.email, + }, + }); - const code = await generateOtp(); + if (!user) { + return { ok: false, reason: "userNotFound" }; + } - const token = await createToken({ - userId: user.id, - code, - }); + const code = await generateOtp(); - await sendEmail("LoginEmail", { - to: input.email, - subject: "Login", - props: { - name: user.name, + const token = await createToken({ + userId: user.id, code, - magicLink: absoluteUrl(`/auth/login?token=${token}`), - }, - }); + }); - return { token }; - }), + await sendEmail("LoginEmail", { + to: input.email, + subject: "Login", + props: { + name: user.name, + code, + magicLink: absoluteUrl(`/auth/login?token=${token}`), + }, + }); + + return { ok: true, token }; + }, + ), authenticateLogin: publicProcedure .input( z.object({ diff --git a/apps/web/src/server/routers/polls.ts b/apps/web/src/server/routers/polls.ts index a1cece593..b6c0f4c08 100644 --- a/apps/web/src/server/routers/polls.ts +++ b/apps/web/src/server/routers/polls.ts @@ -8,7 +8,7 @@ import { createToken, EnableNotificationsTokenPayload } from "@/utils/auth"; import { absoluteUrl } from "../../utils/absolute-url"; import { nanoid } from "../../utils/nanoid"; import { GetPollApiResponse } from "../../utils/trpc/types"; -import { publicProcedure, router } from "../trpc"; +import { possiblyPublicProcedure, publicProcedure, router } from "../trpc"; import { comments } from "./polls/comments"; import { demo } from "./polls/demo"; import { participants } from "./polls/participants"; @@ -78,7 +78,7 @@ const getPollIdFromAdminUrlId = async (urlId: string) => { export const polls = router({ // START LEGACY ROUTES - create: publicProcedure + create: possiblyPublicProcedure .input( z.object({ title: z.string(), @@ -168,7 +168,7 @@ export const polls = router({ return { id: poll.id, urlId: adminUrlId }; }, ), - update: publicProcedure + update: possiblyPublicProcedure .input( z.object({ urlId: z.string(), @@ -222,7 +222,7 @@ export const polls = router({ return { ...poll }; }), - delete: publicProcedure + delete: possiblyPublicProcedure .input( z.object({ urlId: z.string(), @@ -253,7 +253,7 @@ export const polls = router({ comments, verification, // END LEGACY ROUTES - enableNotifications: publicProcedure + enableNotifications: possiblyPublicProcedure .input(z.object({ adminUrlId: z.string() })) .mutation(async ({ input }) => { const poll = await prisma.poll.findUnique({ @@ -293,7 +293,7 @@ export const polls = router({ }, }); }), - getByAdminUrlId: publicProcedure + getByAdminUrlId: possiblyPublicProcedure .input( z.object({ urlId: z.string(), diff --git a/apps/web/src/server/routers/polls/demo.ts b/apps/web/src/server/routers/polls/demo.ts index 8154afd7b..481b792f9 100644 --- a/apps/web/src/server/routers/polls/demo.ts +++ b/apps/web/src/server/routers/polls/demo.ts @@ -2,7 +2,7 @@ import { prisma, VoteType } from "@rallly/database"; import dayjs from "dayjs"; import { nanoid } from "../../../utils/nanoid"; -import { publicProcedure, router } from "../../trpc"; +import { possiblyPublicProcedure, router } from "../../trpc"; const participantData: Array<{ name: string; votes: VoteType[] }> = [ { @@ -26,7 +26,7 @@ const participantData: Array<{ name: string; votes: VoteType[] }> = [ const optionValues = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"]; export const demo = router({ - create: publicProcedure.mutation(async () => { + create: possiblyPublicProcedure.mutation(async () => { const adminUrlId = await nanoid(); const demoUser = { name: "John Example", email: "noreply@rallly.co" }; diff --git a/apps/web/src/server/trpc.ts b/apps/web/src/server/trpc.ts index b53832165..7ca006db5 100644 --- a/apps/web/src/server/trpc.ts +++ b/apps/web/src/server/trpc.ts @@ -1,4 +1,4 @@ -import { initTRPC } from "@trpc/server"; +import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { Context } from "./context"; @@ -16,4 +16,13 @@ export const publicProcedure = t.procedure; export const middleware = t.middleware; +const checkAuthIfRequired = middleware(async ({ ctx, next }) => { + if (process.env.AUTH_REQUIRED === "true" && ctx.session.user.isGuest) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required" }); + } + return next(); +}); + +export const possiblyPublicProcedure = t.procedure.use(checkAuthIfRequired); + export const mergeRouters = t.mergeRouters; diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index 67c2f9048..b96373b51 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -101,6 +101,34 @@ export const composeGetServerSideProps = ( }; }; +/** + * Require user to be logged in + * @returns + */ +export const withAuth: GetServerSideProps = async (ctx) => { + if (!ctx.req.session.user || ctx.req.session.user.isGuest) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + return { props: {} }; +}; + +/** + * Require user to be logged in if AUTH_REQUIRED is true + * @returns + */ +export const withAuthIfRequired: GetServerSideProps = async (ctx) => { + if (process.env.AUTH_REQUIRED === "true") { + return await withAuth(ctx); + } + return { props: {} }; +}; + export function withSessionSsr( handler: GetServerSideProps | GetServerSideProps[], options?: { diff --git a/turbo.json b/turbo.json index aa39b073f..d86002e69 100644 --- a/turbo.json +++ b/turbo.json @@ -6,9 +6,11 @@ "dependsOn": ["^build"], "outputs": [".next/**"], "env": [ + "ALLOWED_EMAILS", + "AUTH_REQUIRED", "ANALYZE", "API_SECRET", - "LANDING_PAGE", + "DISABLE_LANDING_PAGE", "MAINTENANCE_MODE", "NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_BETA",