mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 10:16:32 +02:00
♻️ Update posthog identification flow (#686)
This commit is contained in:
parent
5fa7436481
commit
85c0307852
6 changed files with 153 additions and 185 deletions
|
@ -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 (
|
||||
|
|
|
@ -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 (
|
||||
<PostHogProvider client={posthog}>
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user: { ...user, shortName },
|
||||
refresh: () => {
|
||||
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}
|
||||
</UserContext.Provider>
|
||||
</PostHogProvider>
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user: { ...user, shortName },
|
||||
refresh: () => {
|
||||
return queryClient.whoami.invalidate();
|
||||
},
|
||||
ownsObject: ({ userId }) => {
|
||||
if (
|
||||
(userId && user.id === userId) ||
|
||||
(props.forceUserId && props.forceUserId === userId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
logout: () => {
|
||||
logout.mutate();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PostHogProvider>{props.children}</PostHogProvider>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
42
apps/web/src/contexts/posthog.tsx
Normal file
42
apps/web/src/contexts/posthog.tsx
Normal 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} />;
|
||||
};
|
|
@ -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;
|
|
@ -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 (
|
||||
<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 />
|
||||
)}
|
||||
</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";
|
||||
setTimeout(() => {
|
||||
router.replace(defaultRedirectPath);
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthLayout title={t("login")}>
|
||||
{props.success ? (
|
||||
<Redirect />
|
||||
{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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue