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 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 (
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
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 { 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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue