♻️ Update posthog identification flow (#686)

This commit is contained in:
Luke Vella 2023-05-02 15:24:07 +01:00 committed by GitHub
parent 5fa7436481
commit 85c0307852
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 153 additions and 185 deletions

View file

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

View file

@ -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,7 +72,6 @@ export const UserProvider = (props: {
}
return (
<PostHogProvider client={posthog}>
<UserContext.Provider
value={{
user: { ...user, shortName },
@ -118,9 +92,8 @@ export const UserProvider = (props: {
},
}}
>
{props.children}
<PostHogProvider>{props.children}</PostHogProvider>
</UserContext.Provider>
</PostHogProvider>
);
};

View file

@ -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 <Provider client={posthog}>{props.children}</Provider>;
};
export const PostHogProvider = (props: PostHogProviderProps) => {
if (!process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
return <>{props.children}</>;
}
return <PostHogProviderInner {...props} />;
};

View file

@ -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 (
<AuthLayout title={t("expiredOrInvalidLink")}>
<NextSeo
title={t("expiredOrInvalidLink")}
nofollow={true}
noindex={true}
/>
{t("expiredOrInvalidLink")}
</AuthLayout>
);
};
export const getStaticProps = withPageTranslations();
export default Page;

View file

@ -1,57 +1,58 @@
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();
React.useEffect(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
const router = useRouter();
const { token } = router.query;
const posthog = usePostHog();
const authenticate = trpc.whoami.authenticate.useMutation();
useMount(() => {
authenticate.mutate(
{ token: token as string },
{
onSuccess: (user) => {
posthog?.identify(user.id, {
name: user.name,
email: user.email,
});
setTimeout(() => {
router.replace(defaultRedirectPath);
}, 3000);
}, [router]);
}, 1000);
},
},
);
});
return (
<AuthLayout title={t("login")}>
{authenticate.isLoading ? (
<div className="flex items-center gap-4">
<Spinner />
<Trans i18nKey="loading" />
</div>
) : authenticate.isSuccess ? (
<div className="space-y-2">
<div className="flex h-10 items-center justify-center gap-4">
{enabled ? (
<CheckCircleIcon
className={clsx("animate-popIn h-8 text-green-500", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
<CheckCircleIcon className={clsx("h-8 text-green-500")} />
</div>
<div className="text-slate-800">{t("loginSuccessful")}</div>
<div className="text-sm text-slate-500">
@ -64,75 +65,17 @@ const Redirect = () => {
/>
</div>
</div>
);
};
export const Page = (
props:
| {
success: true;
name: string;
}
| {
success: false;
errorCode: "userNotFound";
},
) => {
const { t } = useTranslation();
return (
<AuthLayout title={t("login")}>
{props.success ? (
<Redirect />
) : (
<Trans t={t} i18nKey="userDoesNotExist" />
<div>
<Trans i18nKey="expiredOrInvalidLink" />
</div>
)}
</AuthLayout>
);
};
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<LoginTokenPayload>(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,
},
};
}),
);

View file

@ -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<UserSession> => {
@ -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<LoginTokenPayload>(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;
}),
});