From 249376c43eb4e287a2cc3e29089c001caeeae8e9 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Mon, 23 Jan 2023 14:19:17 +0000 Subject: [PATCH] Prefetch user (#429) --- src/components/create-poll.tsx | 8 +- src/components/discussion/discussion.tsx | 4 +- src/components/poll-context.tsx | 4 +- src/components/poll.tsx | 4 +- .../poll/desktop-poll/participant-row.tsx | 4 +- src/components/poll/mobile-poll.tsx | 4 +- src/components/poll/mutations.ts | 4 +- src/components/profile.tsx | 4 +- src/components/profile/user-details.tsx | 4 +- src/components/session.tsx | 117 ------------------ src/components/standard-layout.tsx | 8 +- src/components/user-provider.tsx | 109 ++++++++++++++++ src/pages/_app.tsx | 5 +- src/pages/new.tsx | 3 +- src/pages/poll.tsx | 2 +- src/pages/profile.tsx | 2 +- src/server/routers/_app.ts | 26 +--- src/server/routers/whoami.ts | 30 +++++ src/utils/auth.ts | 58 +++++++-- tests/vote-and-comment.spec.ts | 1 - 20 files changed, 220 insertions(+), 181 deletions(-) delete mode 100644 src/components/session.tsx create mode 100644 src/components/user-provider.tsx create mode 100644 src/server/routers/whoami.ts diff --git a/src/components/create-poll.tsx b/src/components/create-poll.tsx index 84a23d04c..ede7a3be8 100644 --- a/src/components/create-poll.tsx +++ b/src/components/create-poll.tsx @@ -18,9 +18,9 @@ import { UserDetailsData, UserDetailsForm, } from "./forms"; -import { SessionProps, useSession, withSession } from "./session"; import StandardLayout from "./standard-layout"; import Steps from "./steps"; +import { useUser } from "./user-provider"; type StepName = "eventDetails" | "options" | "userDetails"; @@ -37,7 +37,7 @@ const required = (v: T | undefined): T => { const initialNewEventData: NewEventData = { currentStep: 0 }; const sessionStorageKey = "newEventFormData"; -export interface CreatePollPageProps extends SessionProps { +export interface CreatePollPageProps { title?: string; location?: string; description?: string; @@ -54,7 +54,7 @@ const Page: NextPage = ({ const router = useRouter(); - const session = useSession(); + const session = useUser(); const [persistedFormData, setPersistedFormData] = useSessionStorage(sessionStorageKey, { @@ -228,4 +228,4 @@ const Page: NextPage = ({ ); }; -export default withSession(Page); +export default Page; diff --git a/src/components/discussion/discussion.tsx b/src/components/discussion/discussion.tsx index 20213bb69..b61220196 100644 --- a/src/components/discussion/discussion.tsx +++ b/src/components/discussion/discussion.tsx @@ -17,7 +17,7 @@ import NameInput from "../name-input"; import TruncatedLinkify from "../poll/truncated-linkify"; import UserAvatar from "../poll/user-avatar"; import { usePoll } from "../poll-context"; -import { isUnclaimed, useSession } from "../session"; +import { isUnclaimed, useUser } from "../user-provider"; interface CommentForm { authorName: string; @@ -68,7 +68,7 @@ const Discussion: React.VoidFunctionComponent = () => { }, }); - const session = useSession(); + const session = useUser(); const { register, reset, control, handleSubmit, formState } = useForm({ diff --git a/src/components/poll-context.tsx b/src/components/poll-context.tsx index 8a6ff2369..a829e8422 100644 --- a/src/components/poll-context.tsx +++ b/src/components/poll-context.tsx @@ -15,8 +15,8 @@ import { GetPollApiResponse } from "@/utils/trpc/types"; import { useDayjs } from "../utils/dayjs"; import ErrorPage from "./error-page"; import { useParticipants } from "./participants-provider"; -import { useSession } from "./session"; import { useRequiredContext } from "./use-required-context"; +import { useUser } from "./user-provider"; type PollContextValue = { userAlreadyVoted: boolean; @@ -61,7 +61,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{ const { t } = useTranslation("app"); const { participants } = useParticipants(); const [isDeleted, setDeleted] = React.useState(false); - const { user } = useSession(); + const { user } = useUser(); const [targetTimeZone, setTargetTimeZone] = React.useState(getBrowserTimeZone); diff --git a/src/components/poll.tsx b/src/components/poll.tsx index 8781a946c..a50e0eec4 100644 --- a/src/components/poll.tsx +++ b/src/components/poll.tsx @@ -28,8 +28,8 @@ import { useTouchBeacon } from "./poll/use-touch-beacon"; import { UserAvatarProvider } from "./poll/user-avatar"; import VoteIcon from "./poll/vote-icon"; import { usePoll } from "./poll-context"; -import { useSession } from "./session"; import Sharing from "./sharing"; +import { useUser } from "./user-provider"; const PollPage: NextPage = () => { const { poll, urlId, admin } = usePoll(); @@ -40,7 +40,7 @@ const PollPage: NextPage = () => { const { t } = useTranslation("app"); - const session = useSession(); + const session = useUser(); const queryClient = trpc.useContext(); const plausible = usePlausible(); diff --git a/src/components/poll/desktop-poll/participant-row.tsx b/src/components/poll/desktop-poll/participant-row.tsx index f1e53d10b..c02309d8d 100644 --- a/src/components/poll/desktop-poll/participant-row.tsx +++ b/src/components/poll/desktop-poll/participant-row.tsx @@ -6,7 +6,7 @@ import CompactButton from "@/components/compact-button"; import Pencil from "@/components/icons/pencil-alt.svg"; import Trash from "@/components/icons/trash.svg"; import { usePoll } from "@/components/poll-context"; -import { useSession } from "@/components/session"; +import { useUser } from "@/components/user-provider"; import { ParticipantFormSubmitted } from "../types"; import { useDeleteParticipantModal } from "../use-delete-participant-modal"; @@ -108,7 +108,7 @@ const ParticipantRow: React.VoidFunctionComponent = ({ const confirmDeleteParticipant = useDeleteParticipantModal(); - const session = useSession(); + const session = useUser(); const { poll, getVote, options } = usePoll(); const isYou = session.user && session.ownsObject(participant) ? true : false; diff --git a/src/components/poll/mobile-poll.tsx b/src/components/poll/mobile-poll.tsx index a96b28e87..7014adc32 100644 --- a/src/components/poll/mobile-poll.tsx +++ b/src/components/poll/mobile-poll.tsx @@ -18,8 +18,8 @@ import { Button } from "../button"; import { styleMenuItem } from "../menu-styles"; import NameInput from "../name-input"; import { useParticipants } from "../participants-provider"; -import { isUnclaimed, useSession } from "../session"; import TimeZonePicker from "../time-zone-picker"; +import { isUnclaimed, useUser } from "../user-provider"; import GroupedOptions from "./mobile-poll/grouped-options"; import { normalizeVotes, @@ -50,7 +50,7 @@ const MobilePoll: React.VoidFunctionComponent = () => { const { participants } = useParticipants(); const { timeZone } = poll; - const session = useSession(); + const session = useUser(); const form = useForm({ defaultValues: { diff --git a/src/components/poll/mutations.ts b/src/components/poll/mutations.ts index 0cca17ed0..48409153e 100644 --- a/src/components/poll/mutations.ts +++ b/src/components/poll/mutations.ts @@ -2,7 +2,7 @@ import { usePlausible } from "next-plausible"; import { trpc } from "../../utils/trpc"; import { usePoll } from "../poll-context"; -import { useSession } from "../session"; +import { useUser } from "../user-provider"; import { ParticipantForm } from "./types"; export const normalizeVotes = ( @@ -17,7 +17,7 @@ export const normalizeVotes = ( export const useAddParticipantMutation = () => { const queryClient = trpc.useContext(); - const session = useSession(); + const session = useUser(); const plausible = usePlausible(); return trpc.useMutation(["polls.participants.add"], { diff --git a/src/components/profile.tsx b/src/components/profile.tsx index 954fb360b..622e61adf 100644 --- a/src/components/profile.tsx +++ b/src/components/profile.tsx @@ -12,10 +12,10 @@ import { trpc } from "../utils/trpc"; import { EmptyState } from "./empty-state"; import LoginForm from "./login-form"; import { UserDetails } from "./profile/user-details"; -import { useSession } from "./session"; +import { useUser } from "./user-provider"; export const Profile: React.VoidFunctionComponent = () => { - const { user } = useSession(); + const { user } = useUser(); const { dayjs } = useDayjs(); const { t } = useTranslation("app"); diff --git a/src/components/profile/user-details.tsx b/src/components/profile/user-details.tsx index 8eabbc85b..fdad067b5 100644 --- a/src/components/profile/user-details.tsx +++ b/src/components/profile/user-details.tsx @@ -6,8 +6,8 @@ import { useForm } from "react-hook-form"; import { requiredString, validEmail } from "../../utils/form-validation"; import { trpc } from "../../utils/trpc"; import { Button } from "../button"; -import { useSession } from "../session"; import { TextInput } from "../text-input"; +import { useUser } from "../user-provider"; export interface UserDetailsProps { userId: string; @@ -30,7 +30,7 @@ export const UserDetails: React.VoidFunctionComponent = ({ defaultValues: { name, email }, }); - const { refresh } = useSession(); + const { refresh } = useUser(); const changeName = trpc.useMutation("user.changeName", { onSuccess: () => { diff --git a/src/components/session.tsx b/src/components/session.tsx deleted file mode 100644 index 7fc617197..000000000 --- a/src/components/session.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { IronSessionData } from "iron-session"; -import React from "react"; -import toast from "react-hot-toast"; - -import { trpc } from "@/utils/trpc"; - -import FullPageLoader from "./full-page-loader"; -import { useRequiredContext } from "./use-required-context"; - -export type UserSessionData = NonNullable; - -export type SessionProps = { - user: UserSessionData; -}; - -type ParticipantOrComment = { - userId: string | null; -}; - -export type UserSessionDataExtended = - | { - isGuest: true; - id: string; - shortName: string; - } - | { - isGuest: false; - id: string; - name: string; - shortName: string; - email: string; - }; - -type SessionContextValue = { - logout: () => void; - user: UserSessionDataExtended; - refresh: () => void; - ownsObject: (obj: ParticipantOrComment) => boolean; - isLoading: boolean; -}; - -export const SessionContext = - React.createContext(null); - -SessionContext.displayName = "SessionContext"; - -export const SessionProvider: React.VoidFunctionComponent<{ - children?: React.ReactNode; -}> = ({ children }) => { - const queryClient = trpc.useContext(); - const { data: user, refetch, isLoading } = trpc.useQuery(["session.get"]); - - const logout = trpc.useMutation(["session.destroy"], { - onSuccess: () => { - queryClient.invalidateQueries(["session.get"]); - }, - }); - - if (!user) { - return Loading user…; - } - - const sessionData: SessionContextValue = { - user: { - ...user, - shortName: - // try to get the first name in the event - // that the user entered a full name - user.isGuest - ? user.id.substring(0, 10) - : user.name.length > 12 && user.name.indexOf(" ") !== -1 - ? user.name.substring(0, user.name.indexOf(" ")) - : user.name, - }, - refresh: () => { - refetch(); - }, - isLoading, - logout: () => { - toast.promise(logout.mutateAsync(), { - loading: "Logging out…", - success: "Logged out", - error: "Failed to log out", - }); - }, - ownsObject: (obj) => { - return obj.userId === user.id; - }, - }; - - return ( - - {children} - - ); -}; - -export const useSession = () => { - return useRequiredContext(SessionContext); -}; - -export const withSession =

( - component: React.ComponentType

, -) => { - const ComposedComponent: React.VoidFunctionComponent

= (props: P) => { - const Component = component; - return ( - - - - ); - }; - ComposedComponent.displayName = component.displayName; - return ComposedComponent; -}; - -export const isUnclaimed = (obj: ParticipantOrComment) => !obj.userId; diff --git a/src/components/standard-layout.tsx b/src/components/standard-layout.tsx index cb48d2d4a..2f9e40c75 100644 --- a/src/components/standard-layout.tsx +++ b/src/components/standard-layout.tsx @@ -27,7 +27,7 @@ import { useModal } from "./modal"; import ModalProvider, { useModalContext } from "./modal/modal-provider"; import Popover from "./popover"; import Preferences from "./preferences"; -import { useSession } from "./session"; +import { useUser } from "./user-provider"; const HomeLink = () => { return ( @@ -40,7 +40,7 @@ const HomeLink = () => { const MobileNavigation: React.VoidFunctionComponent<{ openLoginModal: () => void; }> = ({ openLoginModal }) => { - const { user } = useSession(); + const { user } = useUser(); const { t } = useTranslation(["common", "app"]); return (

= ({ const UserDropdown: React.VoidFunctionComponent< DropdownProps & { openLoginModal: () => void } > = ({ children, openLoginModal, ...forwardProps }) => { - const { logout, user } = useSession(); + const { logout, user } = useUser(); const { t } = useTranslation(["common", "app"]); const modalContext = useModalContext(); if (!user) { @@ -243,7 +243,7 @@ const UserDropdown: React.VoidFunctionComponent< const StandardLayout: React.VoidFunctionComponent<{ children?: React.ReactNode; }> = ({ children, ...rest }) => { - const { user } = useSession(); + const { user } = useUser(); const { t } = useTranslation(["common", "app"]); const [loginModal, openLoginModal] = useModal({ footer: null, diff --git a/src/components/user-provider.tsx b/src/components/user-provider.tsx new file mode 100644 index 000000000..024f65bf6 --- /dev/null +++ b/src/components/user-provider.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "next-i18next"; +import React from "react"; + +import { UserSession } from "@/utils/auth"; + +import { trpcNext } from "../utils/trpc"; +import { useRequiredContext } from "./use-required-context"; + +export const UserContext = + React.createContext<{ + user: UserSession & { shortName: string }; + refresh: () => void; + logout: () => Promise; + ownsObject: (obj: { userId: string | null }) => boolean; + } | null>(null); + +export const useUser = () => { + return useRequiredContext(UserContext, "UserContext"); +}; + +export const useAuthenticatedUser = () => { + const { user, ...rest } = useRequiredContext(UserContext, "UserContext"); + if (user.isGuest) { + throw new Error("Forget to prefetch user identity"); + } + + return { user, ...rest }; +}; + +export const IfAuthenticated = (props: { children?: React.ReactNode }) => { + const { user } = useUser(); + if (user.isGuest) { + return null; + } + + return <>{props.children}; +}; + +export const IfGuest = (props: { children?: React.ReactNode }) => { + const { user } = useUser(); + if (!user.isGuest) { + return null; + } + + return <>{props.children}; +}; + +export const UserProvider = (props: { children?: React.ReactNode }) => { + const { t } = useTranslation("app"); + + const { data: user, refetch } = trpcNext.whoami.get.useQuery(); + const logout = trpcNext.whoami.destroy.useMutation(); + + const shortName = user + ? user.isGuest === false + ? user.name + : `${t("guest")}-${user.id.substring(user.id.length - 4)}` + : t("guest"); + + if (!user) { + return null; + } + + return ( + { + if (userId && user.id === userId) { + return true; + } + return false; + }, + logout: async () => { + await logout.mutateAsync(); + refetch(); + }, + }} + > + {props.children} + + ); +}; + +type ParticipantOrComment = { + userId: string | null; +}; + +// eslint-disable-next-line @typescript-eslint/ban-types +export const withSession =

( + component: React.ComponentType

, +) => { + const ComposedComponent: React.VoidFunctionComponent

= (props: P) => { + const Component = component; + return ( + + + + ); + }; + ComposedComponent.displayName = `withUser(${component.displayName})`; + return ComposedComponent; +}; + +/** + * @deprecated Stop using this function. All object + */ +export const isUnclaimed = (obj: ParticipantOrComment) => !obj.userId; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 41a180a19..f615b947c 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -49,10 +49,7 @@ const MyApp: NextPage = ({ Component, pageProps }) => { customDomain={process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN} trackOutboundLinks={true} selfHosted={true} - enabled={ - typeof window !== undefined && - !!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN - } + enabled={!!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN} > { diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index f3376c303..1ee08b9ee 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -1,13 +1,10 @@ -import { z } from "zod"; - -import { prisma } from "~/prisma/db"; - import { createRouter } from "../createRouter"; -import { mergeRouters, publicProcedure, router } from "../trpc"; +import { mergeRouters, router } from "../trpc"; import { login } from "./login"; import { polls } from "./polls"; import { session } from "./session"; import { user } from "./user"; +import { whoami } from "./whoami"; const legacyRouter = createRouter() .merge("user.", user) @@ -18,24 +15,7 @@ const legacyRouter = createRouter() export const appRouter = mergeRouters( legacyRouter.interop(), router({ - p: router({ - touch: publicProcedure - .input( - z.object({ - pollId: z.string(), - }), - ) - .mutation(async ({ input }) => { - await prisma.poll.update({ - where: { - id: input.pollId, - }, - data: { - touchedAt: new Date(), - }, - }); - }), - }), + whoami, }), ); diff --git a/src/server/routers/whoami.ts b/src/server/routers/whoami.ts new file mode 100644 index 000000000..17da94cfa --- /dev/null +++ b/src/server/routers/whoami.ts @@ -0,0 +1,30 @@ +import { prisma } from "~/prisma/db"; + +import { createGuestUser, UserSession } from "../../utils/auth"; +import { publicProcedure, router } from "../trpc"; + +export const whoami = router({ + get: publicProcedure.query(async ({ ctx }): Promise => { + if (ctx.user.isGuest) { + return { isGuest: true, id: ctx.user.id }; + } + + const user = await prisma.user.findUnique({ + select: { id: true, name: true, email: true }, + where: { id: ctx.user.id }, + }); + + if (user === null) { + const guestUser = await createGuestUser(); + ctx.session.user = guestUser; + await ctx.session.save(); + + return guestUser; + } + + return { isGuest: false, ...user }; + }), + destroy: publicProcedure.mutation(async ({ ctx }) => { + ctx.session.destroy(); + }), +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index baac5b9cb..846a0eb83 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -9,6 +9,7 @@ import { GetServerSideProps, NextApiHandler } from "next"; import { prisma } from "~/prisma/db"; +import { createSSGHelperFromContext } from "../server/context"; import { randomid } from "./nanoid"; const sessionOptions: IronSessionOptions = { @@ -20,12 +21,42 @@ const sessionOptions: IronSessionOptions = { ttl: 0, // basically forever }; +export type RegisteredUserSession = { + isGuest: false; + id: string; + name: string; + email: string; +}; + +export type GuestUserSession = { + isGuest: true; + id: string; +}; + +export type UserSession = GuestUserSession | RegisteredUserSession; + +const setUser = async (session: IronSession) => { + if (!session.user) { + session.user = await createGuestUser(); + await session.save(); + } + + if (!session.user.isGuest) { + // Check registered user still exists + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + }); + + if (!user) { + session.user = await createGuestUser(); + await session.save(); + } + } +}; + export function withSessionRoute(handler: NextApiHandler) { return withIronSessionApiRoute(async (req, res) => { - if (!req.session.user) { - req.session.user = await createGuestUser(); - await req.session.save(); - } + await setUser(req.session); return await handler(req, res); }, sessionOptions); } @@ -33,14 +64,23 @@ export function withSessionRoute(handler: NextApiHandler) { export function withSessionSsr(handler: GetServerSideProps) { return withIronSessionSsr(async (context) => { const { req } = context; - if (!req.session.user) { - req.session.user = await createGuestUser(); - await req.session.save(); - } + + await setUser(req.session); + + const ssg = await createSSGHelperFromContext(context); + await ssg.whoami.get.prefetch(); + const res = await handler(context); if ("props" in res) { - return { ...res, props: { ...res.props, user: req.session.user } }; + return { + ...res, + props: { + ...res.props, + user: req.session.user, + trpcState: ssg.dehydrate(), + }, + }; } return res; diff --git a/tests/vote-and-comment.spec.ts b/tests/vote-and-comment.spec.ts index 1aa171872..b563e01b9 100644 --- a/tests/vote-and-comment.spec.ts +++ b/tests/vote-and-comment.spec.ts @@ -18,7 +18,6 @@ test("should be able to vote and comment on a poll", async ({ page }) => { await page.locator("data-testid=vote-selector >> nth=2").click(); await page.click("text='Save'"); await expect(page.locator("text='Test user'")).toBeVisible(); - await expect(page.locator("text=Guest")).toBeVisible(); await expect( page.locator("data-testid=participant-row >> nth=4").locator("text=You"), ).toBeVisible();