♻️ 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 React from "react";
import Logo from "~//logo.svg"; import Logo from "~/logo.svg";
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => { export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
return ( return (

View file

@ -1,23 +1,11 @@
import { trpc, UserSession } from "@rallly/backend"; import { trpc, UserSession } from "@rallly/backend";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import React from "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) { import { useRequiredContext } from "./use-required-context";
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,
});
}
export const UserContext = React.createContext<{ export const UserContext = React.createContext<{
user: UserSession & { shortName: string }; 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 const shortName = user
? user.isGuest === false ? user.isGuest === false
? user.name ? user.name
@ -97,30 +72,28 @@ export const UserProvider = (props: {
} }
return ( return (
<PostHogProvider client={posthog}> <UserContext.Provider
<UserContext.Provider value={{
value={{ user: { ...user, shortName },
user: { ...user, shortName }, refresh: () => {
refresh: () => { return queryClient.whoami.invalidate();
return queryClient.whoami.invalidate(); },
}, ownsObject: ({ userId }) => {
ownsObject: ({ userId }) => { if (
if ( (userId && user.id === userId) ||
(userId && user.id === userId) || (props.forceUserId && props.forceUserId === userId)
(props.forceUserId && props.forceUserId === userId) ) {
) { return true;
return true; }
} return false;
return false; },
}, logout: () => {
logout: () => { logout.mutate();
logout.mutate(); },
}, }}
}} >
> <PostHogProvider>{props.children}</PostHogProvider>
{props.children} </UserContext.Provider>
</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,138 +1,81 @@
import { LoginTokenPayload } from "@rallly/backend"; import { trpc } from "@rallly/backend";
import { import { withSessionSsr } from "@rallly/backend/next";
composeGetServerSideProps,
withSessionSsr,
} from "@rallly/backend/next";
import { decryptToken } from "@rallly/backend/session";
import { prisma } from "@rallly/database";
import { CheckCircleIcon } from "@rallly/icons"; import { CheckCircleIcon } from "@rallly/icons";
import clsx from "clsx"; import clsx from "clsx";
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next"; import { Trans, useTranslation } from "next-i18next";
import React from "react"; import { useMount } from "react-use";
import { AuthLayout } from "@/components/layouts/auth-layout"; import { AuthLayout } from "@/components/layouts/auth-layout";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
import { withSession } from "@/components/user-provider";
import { usePostHog } from "@/utils/posthog";
import { withPageTranslations } from "@/utils/with-page-translations"; import { withPageTranslations } from "@/utils/with-page-translations";
const defaultRedirectPath = "/profile"; const defaultRedirectPath = "/profile";
const redirectToInvalidToken = { export const Page = () => {
redirect: {
destination: "/auth/invalid-token",
permanent: false,
},
};
const Redirect = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [enabled, setEnabled] = React.useState(false);
const router = useRouter(); const router = useRouter();
const { token } = router.query;
const posthog = usePostHog();
const authenticate = trpc.whoami.authenticate.useMutation();
React.useEffect(() => { useMount(() => {
setTimeout(() => { authenticate.mutate(
setEnabled(true); { token: token as string },
}, 500); {
setTimeout(() => { onSuccess: (user) => {
router.replace(defaultRedirectPath); posthog?.identify(user.id, {
}, 3000); name: user.name,
}, [router]); email: user.email,
});
return ( setTimeout(() => {
<div className="space-y-2"> router.replace(defaultRedirectPath);
<div className="flex h-10 items-center justify-center gap-4"> }, 1000);
{enabled ? ( },
<CheckCircleIcon
className={clsx("animate-popIn h-8 text-green-500", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-slate-800">{t("loginSuccessful")}</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={defaultRedirectPath} />,
}}
/>
</div>
</div>
);
};
export const Page = (
props:
| {
success: true;
name: string;
}
| {
success: false;
errorCode: "userNotFound";
}, },
) => { );
const { t } = useTranslation(); });
return ( return (
<AuthLayout title={t("login")}> <AuthLayout title={t("login")}>
{props.success ? ( {authenticate.isLoading ? (
<Redirect /> <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">
<CheckCircleIcon className={clsx("h-8 text-green-500")} />
</div>
<div className="text-slate-800">{t("loginSuccessful")}</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={defaultRedirectPath} />,
}}
/>
</div>
</div>
) : ( ) : (
<Trans t={t} i18nKey="userDoesNotExist" /> <div>
<Trans i18nKey="expiredOrInvalidLink" />
</div>
)} )}
</AuthLayout> </AuthLayout>
); );
}; };
export default Page; export default withSession(Page);
export const getServerSideProps: GetServerSideProps = composeGetServerSideProps( export const getServerSideProps: GetServerSideProps = withSessionSsr(
withPageTranslations(), 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 { prisma } from "@rallly/database";
import { TRPCError } from "@trpc/server";
import z from "zod";
import { decryptToken } from "../../session";
import { publicProcedure, router } from "../trpc"; import { publicProcedure, router } from "../trpc";
import { UserSession } from "../types"; import { LoginTokenPayload, UserSession } from "../types";
export const whoami = router({ export const whoami = router({
get: publicProcedure.query(async ({ ctx }): Promise<UserSession> => { get: publicProcedure.query(async ({ ctx }): Promise<UserSession> => {
@ -24,4 +27,34 @@ export const whoami = router({
destroy: publicProcedure.mutation(async ({ ctx }) => { destroy: publicProcedure.mutation(async ({ ctx }) => {
ctx.session.destroy(); 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;
}),
}); });