diff --git a/apps/web/src/components/auth/auth-layout.tsx b/apps/web/src/components/auth/auth-layout.tsx index 77a146022..19043c473 100644 --- a/apps/web/src/components/auth/auth-layout.tsx +++ b/apps/web/src/components/auth/auth-layout.tsx @@ -1,6 +1,6 @@ import React from "react"; -import Logo from "~//logo.svg"; +import Logo from "~/logo.svg"; export const AuthLayout = ({ children }: { children?: React.ReactNode }) => { return ( diff --git a/apps/web/src/components/user-provider.tsx b/apps/web/src/components/user-provider.tsx index 2780e843d..b72ff7746 100644 --- a/apps/web/src/components/user-provider.tsx +++ b/apps/web/src/components/user-provider.tsx @@ -1,23 +1,11 @@ import { trpc, UserSession } from "@rallly/backend"; import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; -import posthog from "posthog-js"; -import { PostHogProvider } from "posthog-js/react"; import React from "react"; -import { useRequiredContext } from "./use-required-context"; +import { PostHogProvider } from "@/contexts/posthog"; -if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST, - opt_out_capturing_by_default: false, - capture_pageview: false, - persistence: "memory", - capture_pageleave: false, - autocapture: false, - opt_in_site_apps: true, - }); -} +import { useRequiredContext } from "./use-required-context"; export const UserContext = React.createContext<{ user: UserSession & { shortName: string }; @@ -73,19 +61,6 @@ export const UserProvider = (props: { }, }); - React.useEffect(() => { - if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY || !user) { - return; - } - - posthog.identify( - user.id, - !user.isGuest - ? { email: user.email, name: user.name } - : { name: user.id }, - ); - }, [user]); - const shortName = user ? user.isGuest === false ? user.name @@ -97,30 +72,28 @@ export const UserProvider = (props: { } return ( - - { - return queryClient.whoami.invalidate(); - }, - ownsObject: ({ userId }) => { - if ( - (userId && user.id === userId) || - (props.forceUserId && props.forceUserId === userId) - ) { - return true; - } - return false; - }, - logout: () => { - logout.mutate(); - }, - }} - > - {props.children} - - + { + return queryClient.whoami.invalidate(); + }, + ownsObject: ({ userId }) => { + if ( + (userId && user.id === userId) || + (props.forceUserId && props.forceUserId === userId) + ) { + return true; + } + return false; + }, + logout: () => { + logout.mutate(); + }, + }} + > + {props.children} + ); }; diff --git a/apps/web/src/contexts/posthog.tsx b/apps/web/src/contexts/posthog.tsx new file mode 100644 index 000000000..ad92d6a75 --- /dev/null +++ b/apps/web/src/contexts/posthog.tsx @@ -0,0 +1,42 @@ +import posthog from "posthog-js"; +import { PostHogProvider as Provider } from "posthog-js/react"; +import { useMount } from "react-use"; + +import { useUser } from "@/components/user-provider"; + +type PostHogProviderProps = React.PropsWithChildren; + +const PostHogProviderInner = (props: PostHogProviderProps) => { + const { user } = useUser(); + + useMount(() => { + // initalize posthog with our user id + if ( + typeof window !== "undefined" && + process.env.NEXT_PUBLIC_POSTHOG_API_KEY + ) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST, + opt_out_capturing_by_default: false, + capture_pageview: true, + persistence: "memory", + capture_pageleave: false, + autocapture: false, + opt_in_site_apps: true, + bootstrap: { + distinctID: user.id, + }, + }); + } + }); + + return {props.children}; +}; + +export const PostHogProvider = (props: PostHogProviderProps) => { + if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + return <>{props.children}; + } + + return ; +}; diff --git a/apps/web/src/pages/auth/invalid-token.tsx b/apps/web/src/pages/auth/invalid-token.tsx deleted file mode 100644 index d5d49f6aa..000000000 --- a/apps/web/src/pages/auth/invalid-token.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useTranslation } from "next-i18next"; -import { NextSeo } from "next-seo"; - -import { AuthLayout } from "@/components/layouts/auth-layout"; -import { withPageTranslations } from "@/utils/with-page-translations"; - -const Page = () => { - const { t } = useTranslation(); - return ( - - - {t("expiredOrInvalidLink")} - - ); -}; - -export const getStaticProps = withPageTranslations(); - -export default Page; diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx index 5fe8b62fa..963787172 100644 --- a/apps/web/src/pages/auth/login.tsx +++ b/apps/web/src/pages/auth/login.tsx @@ -1,138 +1,81 @@ -import { LoginTokenPayload } from "@rallly/backend"; -import { - composeGetServerSideProps, - withSessionSsr, -} from "@rallly/backend/next"; -import { decryptToken } from "@rallly/backend/session"; -import { prisma } from "@rallly/database"; +import { trpc } from "@rallly/backend"; +import { withSessionSsr } from "@rallly/backend/next"; import { CheckCircleIcon } from "@rallly/icons"; import clsx from "clsx"; import { GetServerSideProps } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; import { Trans, useTranslation } from "next-i18next"; -import React from "react"; +import { useMount } from "react-use"; import { AuthLayout } from "@/components/layouts/auth-layout"; import { Spinner } from "@/components/spinner"; +import { withSession } from "@/components/user-provider"; +import { usePostHog } from "@/utils/posthog"; import { withPageTranslations } from "@/utils/with-page-translations"; const defaultRedirectPath = "/profile"; -const redirectToInvalidToken = { - redirect: { - destination: "/auth/invalid-token", - permanent: false, - }, -}; - -const Redirect = () => { +export const Page = () => { const { t } = useTranslation(); - const [enabled, setEnabled] = React.useState(false); + const router = useRouter(); + const { token } = router.query; + const posthog = usePostHog(); + const authenticate = trpc.whoami.authenticate.useMutation(); - React.useEffect(() => { - setTimeout(() => { - setEnabled(true); - }, 500); - setTimeout(() => { - router.replace(defaultRedirectPath); - }, 3000); - }, [router]); + useMount(() => { + authenticate.mutate( + { token: token as string }, + { + onSuccess: (user) => { + posthog?.identify(user.id, { + name: user.name, + email: user.email, + }); - return ( -
-
- {enabled ? ( - - ) : ( - - )} -
-
{t("loginSuccessful")}
-
- , - }} - /> -
-
- ); -}; -export const Page = ( - props: - | { - success: true; - name: string; - } - | { - success: false; - errorCode: "userNotFound"; + setTimeout(() => { + router.replace(defaultRedirectPath); + }, 1000); + }, }, -) => { - const { t } = useTranslation(); + ); + }); + return ( - {props.success ? ( - + {authenticate.isLoading ? ( +
+ + +
+ ) : authenticate.isSuccess ? ( +
+
+ +
+
{t("loginSuccessful")}
+
+ , + }} + /> +
+
) : ( - +
+ +
)}
); }; -export default Page; +export default withSession(Page); -export const getServerSideProps: GetServerSideProps = composeGetServerSideProps( +export const getServerSideProps: GetServerSideProps = withSessionSsr( withPageTranslations(), - withSessionSsr(async (ctx) => { - const token = ctx.query.token as string; - - if (!token) { - // token is missing - return redirectToInvalidToken; - } - - const payload = await decryptToken(token); - - if (!payload) { - // token is invalid or expired - return redirectToInvalidToken; - } - - const user = await prisma.user.findFirst({ - select: { - id: true, - }, - where: { id: payload.userId }, - }); - - if (!user) { - // user does not exist - return { - props: { - success: false, - errorCode: "userNotFound", - }, - }; - } - - ctx.req.session.user = { id: user.id, isGuest: false }; - - await ctx.req.session.save(); - - return { - props: { - success: true, - }, - }; - }), ); diff --git a/packages/backend/trpc/routers/whoami.ts b/packages/backend/trpc/routers/whoami.ts index a733f65a1..87bc98882 100644 --- a/packages/backend/trpc/routers/whoami.ts +++ b/packages/backend/trpc/routers/whoami.ts @@ -1,7 +1,10 @@ import { prisma } from "@rallly/database"; +import { TRPCError } from "@trpc/server"; +import z from "zod"; +import { decryptToken } from "../../session"; import { publicProcedure, router } from "../trpc"; -import { UserSession } from "../types"; +import { LoginTokenPayload, UserSession } from "../types"; export const whoami = router({ get: publicProcedure.query(async ({ ctx }): Promise => { @@ -24,4 +27,34 @@ export const whoami = router({ destroy: publicProcedure.mutation(async ({ ctx }) => { ctx.session.destroy(); }), + authenticate: publicProcedure + .input(z.object({ token: z.string() })) + .mutation(async ({ ctx, input }) => { + const payload = await decryptToken(input.token); + + if (!payload) { + // token is invalid or expired + throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid token" }); + } + + const user = await prisma.user.findFirst({ + select: { + id: true, + name: true, + email: true, + }, + where: { id: payload.userId }, + }); + + if (!user) { + // user does not exist + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + ctx.session.user = { id: user.id, isGuest: false }; + + await ctx.session.save(); + + return user; + }), });