♻️ Avoid getServerSideProps for login/register (#755)

This commit is contained in:
Luke Vella 2023-07-14 18:09:14 +01:00 committed by GitHub
parent becc7a7930
commit 438c4ec35b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 98 additions and 197 deletions

View file

@ -68,7 +68,6 @@
"no": "No", "no": "No",
"noDatesSelected": "No dates selected", "noDatesSelected": "No dates selected",
"notificationsDisabled": "Notifications have been disabled for <b>{title}</b>", "notificationsDisabled": "Notifications have been disabled for <b>{title}</b>",
"notRegistered": "Create a new account →",
"noVotes": "No one has voted for this option", "noVotes": "No one has voted for this option",
"ok": "Ok", "ok": "Ok",
"optional": "optional", "optional": "optional",

View file

@ -1,4 +1,5 @@
import { trpc } from "@rallly/backend"; import { trpc } from "@rallly/backend";
import { ArrowRightIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import Link from "next/link"; import Link from "next/link";
import { Trans, useTranslation } from "next-i18next"; import { Trans, useTranslation } from "next-i18next";
@ -98,7 +99,7 @@ const VerifyCode: React.FunctionComponent<{
{t("verificationCodeHelp")} {t("verificationCodeHelp")}
</p> </p>
</fieldset> </fieldset>
<div className="mt-6 space-y-4 sm:flex sm:space-x-3 sm:space-y-0"> <div className="mt-6 flex flex-col gap-2 sm:flex-row">
<Button <Button
loading={formState.isSubmitting || formState.isSubmitSuccessful} loading={formState.isSubmitting || formState.isSubmitSuccessful}
type="submit" type="submit"
@ -128,7 +129,7 @@ type RegisterFormData = {
export const RegisterForm: React.FunctionComponent<{ export const RegisterForm: React.FunctionComponent<{
onClickLogin?: React.MouseEventHandler; onClickLogin?: React.MouseEventHandler;
onRegistered: () => void; onRegistered?: () => void;
defaultValues?: Partial<RegisterFormData>; defaultValues?: Partial<RegisterFormData>;
}> = ({ onClickLogin, onRegistered, defaultValues }) => { }> = ({ onClickLogin, onRegistered, defaultValues }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -157,7 +158,7 @@ export const RegisterForm: React.FunctionComponent<{
queryClient.invalidate(); queryClient.invalidate();
onRegistered(); onRegistered?.();
posthog?.identify(res.user.id, { posthog?.identify(res.user.id, {
email: res.user.email, email: res.user.email,
name: res.user.name, name: res.user.name,
@ -252,7 +253,7 @@ export const RegisterForm: React.FunctionComponent<{
loading={formState.isSubmitting} loading={formState.isSubmitting}
type="submit" type="submit"
variant="primary" variant="primary"
className="h-12 px-6" size="lg"
> >
{t("continue")} {t("continue")}
</Button> </Button>
@ -280,7 +281,7 @@ export const LoginForm: React.FunctionComponent<{
e: React.MouseEvent<HTMLAnchorElement>, e: React.MouseEvent<HTMLAnchorElement>,
email: string, email: string,
) => void; ) => void;
onAuthenticated: () => void; onAuthenticated?: () => void;
}> = ({ onAuthenticated, onClickRegister }) => { }> = ({ onAuthenticated, onClickRegister }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { register, handleSubmit, getValues, formState, setError } = useForm<{ const { register, handleSubmit, getValues, formState, setError } = useForm<{
@ -305,7 +306,7 @@ export const LoginForm: React.FunctionComponent<{
if (!res.user) { if (!res.user) {
throw new Error("Failed to authenticate user"); throw new Error("Failed to authenticate user");
} else { } else {
onAuthenticated(); onAuthenticated?.();
queryClient.invalidate(); queryClient.invalidate();
posthog?.identify(res.user.id, { posthog?.identify(res.user.id, {
email: res.user.email, email: res.user.email,
@ -395,25 +396,27 @@ export const LoginForm: React.FunctionComponent<{
</div> </div>
) : null} ) : null}
</fieldset> </fieldset>
<div className="space-y-3"> <div className="flex flex-col gap-2">
<Button <Button
loading={formState.isSubmitting} loading={formState.isSubmitting}
type="submit" type="submit"
size="lg" size="lg"
variant="primary" variant="primary"
className="h-12 w-full px-6" className=""
> >
{t("continue")} {t("continue")}
</Button> </Button>
<Button size="lg" asChild>
<Link <Link
href="/register" href="/register"
className="btn-default h-12 w-full px-6"
onClick={(e) => { onClick={(e) => {
onClickRegister?.(e, getValues("email")); onClickRegister?.(e, getValues("email"));
}} }}
> >
{t("notRegistered")} {t("createAnAccount")}
<ArrowRightIcon className="h-4 w-4" />
</Link> </Link>
</Button>
</div> </div>
</form> </form>
); );

View file

@ -1,92 +0,0 @@
import { Dialog, DialogContent } from "@rallly/ui/dialog";
import Image from "next/image";
import React from "react";
import { LoginForm, RegisterForm } from "./login-form";
export const LoginModal: React.FunctionComponent<{
open: boolean;
defaultView?: "login" | "register";
onOpenChange: (open: boolean) => void;
}> = ({ open, onOpenChange, defaultView = "login" }) => {
const [newAccount, setNewAccount] = React.useState(
defaultView === "register",
);
const [defaultEmail, setDefaultEmail] = React.useState("");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0">
<div
data-testid="login-modal"
className="border-t-primary max-w-full overflow-hidden border-t-4 shadow-sm"
>
<div className="bg-pattern flex justify-center py-8">
<Image
src="/static/logo.svg"
width={140}
height={30}
alt="Rallly"
/>
</div>
<div className="p-4 sm:p-6">
{newAccount ? (
<RegisterForm
defaultValues={{ email: defaultEmail }}
onRegistered={() => onOpenChange(false)}
onClickLogin={(e) => {
e.preventDefault();
setNewAccount(false);
}}
/>
) : (
<LoginForm
onAuthenticated={() => onOpenChange(false)}
onClickRegister={(e, email) => {
e.preventDefault();
setDefaultEmail(email);
setNewAccount(true);
}}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
export const LoginModalProvider = ({ children }: React.PropsWithChildren) => {
const [open, setOpen] = React.useState(false);
const [view, setView] = React.useState<"login" | "register">("login");
React.useEffect(() => {
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const href = target.getAttribute("href");
if (
target.tagName === "A" &&
(href === "/login" || href === "/register")
) {
// Handle the click event here
event.preventDefault();
setView(href === "/login" ? "login" : "register");
setOpen(true);
}
};
document.addEventListener("click", handleClick, { capture: true });
return () => {
document.removeEventListener("click", handleClick, { capture: true });
};
}, []);
return (
<>
{open ? (
<LoginModal open={open} defaultView={view} onOpenChange={setOpen} />
) : null}
{children}
</>
);
};

View file

@ -1,24 +0,0 @@
import { SpinnerIcon } from "@rallly/icons";
import clsx from "clsx";
import * as React from "react";
interface FullPageLoaderProps {
className?: string;
children?: React.ReactNode;
}
const FullPageLoader: React.FunctionComponent<FullPageLoaderProps> = ({
children,
className,
}) => {
return (
<div className={clsx("flex h-full items-center justify-center", className)}>
<div className="bg-primary-600 flex items-center rounded-lg px-4 py-3 text-sm text-white shadow-sm">
<SpinnerIcon className="mr-3 h-5 animate-spin" />
{children}
</div>
</div>
);
};
export default FullPageLoader;

View file

@ -11,7 +11,6 @@ import { useInterval } from "react-use";
import spacetime from "spacetime"; import spacetime from "spacetime";
import soft from "timezone-soft"; import soft from "timezone-soft";
import { LoginModalProvider } from "@/components/auth/login-modal";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import FeedbackButton from "@/components/feedback"; import FeedbackButton from "@/components/feedback";
import { Spinner } from "@/components/spinner"; import { Spinner } from "@/components/spinner";
@ -154,13 +153,11 @@ export const StandardLayout: React.FunctionComponent<{
<UserProvider> <UserProvider>
<Toaster /> <Toaster />
<ModalProvider> <ModalProvider>
<LoginModalProvider>
<div className="flex min-h-screen flex-col" {...rest}> <div className="flex min-h-screen flex-col" {...rest}>
<MainNav /> <MainNav />
<div>{children}</div> <div>{children}</div>
</div> </div>
{process.env.NEXT_PUBLIC_FEEDBACK_EMAIL ? <FeedbackButton /> : null} {process.env.NEXT_PUBLIC_FEEDBACK_EMAIL ? <FeedbackButton /> : null}
</LoginModalProvider>
</ModalProvider> </ModalProvider>
</UserProvider> </UserProvider>
); );

View file

@ -1,9 +1,9 @@
import { SpinnerIcon } from "@rallly/icons"; import { Loader2Icon } from "@rallly/icons";
import clsx from "clsx"; import clsx from "clsx";
export const Spinner = (props: { className?: string }) => { export const Spinner = (props: { className?: string }) => {
return ( return (
<SpinnerIcon <Loader2Icon
className={clsx("inline-block h-5 animate-spin", props.className)} className={clsx("inline-block h-5 animate-spin", props.className)}
/> />
); );

View file

@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import { PostHogProvider } from "@/contexts/posthog"; import { PostHogProvider } from "@/contexts/posthog";
import { useWhoAmI } from "@/contexts/whoami";
import { useRequiredContext } from "./use-required-context"; import { useRequiredContext } from "./use-required-context";
@ -48,7 +49,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
const queryClient = trpc.useContext(); const queryClient = trpc.useContext();
const { data: user } = trpc.whoami.get.useQuery(); const user = useWhoAmI();
const billingQuery = trpc.user.getBilling.useQuery(); const billingQuery = trpc.user.getBilling.useQuery();
const { data: userPreferences } = trpc.userPreferences.get.useQuery(); const { data: userPreferences } = trpc.userPreferences.get.useQuery();

View file

@ -0,0 +1,6 @@
import { trpc } from "@rallly/backend";
export const useWhoAmI = () => {
const { data: whoAmI } = trpc.whoami.get.useQuery();
return whoAmI;
};

View file

@ -15,24 +15,23 @@ export async function middleware(req: NextRequest) {
const newUrl = nextUrl.clone(); const newUrl = nextUrl.clone();
const res = NextResponse.next(); const res = NextResponse.next();
const session = await getSession(req, res); const session = await getSession(req, res);
if (
// a protected path is one that requires to be logged in
const isProtectedPath = protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
const isProtectedPathDueToRequiredAuth =
process.env.AUTH_REQUIRED && process.env.AUTH_REQUIRED &&
session.user?.isGuest !== false &&
!publicPaths.some((publicPath) => !publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath), req.nextUrl.pathname.startsWith(publicPath),
) );
) {
newUrl.pathname = "/login";
return NextResponse.redirect(newUrl);
}
if ( const isGuest = session.user?.isGuest !== false;
session.user?.isGuest !== false &&
protectedPaths.some((protectedPath) => if (isGuest && (isProtectedPathDueToRequiredAuth || isProtectedPath)) {
req.nextUrl.pathname.includes(protectedPath),
)
) {
newUrl.pathname = "/login"; newUrl.pathname = "/login";
newUrl.searchParams.set("redirect", req.nextUrl.pathname);
return NextResponse.redirect(newUrl); return NextResponse.redirect(newUrl);
} }

View file

@ -1,8 +1,5 @@
import { import { Loader2Icon } from "@rallly/icons";
composeGetServerSideProps, import { NextPage } from "next";
withSessionSsr,
} from "@rallly/backend/next";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
@ -10,37 +7,45 @@ import React from "react";
import { AuthLayout } from "@/components/auth/auth-layout"; import { AuthLayout } from "@/components/auth/auth-layout";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import { PageDialog } from "@/components/page-dialog";
import { useWhoAmI } from "@/contexts/whoami";
import { withPageTranslations } from "../utils/with-page-translations"; import { getStaticTranslations } from "../utils/with-page-translations";
const Page: NextPage<{ referer: string | null }> = () => { const Redirect = () => {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
React.useEffect(() => {
router.replace((router.query.redirect as string) ?? "/");
});
return ( return (
<AuthLayout> <PageDialog>
<Head> <Loader2Icon className="h-10 w-10 animate-spin text-gray-400" />
<title>{t("login")}</title> </PageDialog>
</Head>
<LoginForm
onAuthenticated={async () => {
router.replace("/polls");
}}
/>
</AuthLayout>
); );
}; };
export const getServerSideProps: GetServerSideProps = withSessionSsr( const Page: NextPage<{ referer: string | null }> = () => {
composeGetServerSideProps(async (ctx) => { const { t } = useTranslation();
if (ctx.req.session.user?.isGuest === false) { const whoami = useWhoAmI();
return {
redirect: { destination: "/polls" }, if (whoami?.isGuest === false) {
props: {}, return <Redirect />;
};
} }
return { props: {} };
}, withPageTranslations()), return (
); <>
<Head>
<title>{t("login")}</title>
</Head>
<AuthLayout>
<LoginForm />
</AuthLayout>
</>
);
};
export default Page; export default Page;
export const getStaticProps = getStaticTranslations;

View file

@ -1,4 +1,3 @@
import { withSessionSsr } from "@rallly/backend/next";
import { NextPage } from "next"; import { NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -6,11 +5,11 @@ import { useTranslation } from "next-i18next";
import { AuthLayout } from "../components/auth/auth-layout"; import { AuthLayout } from "../components/auth/auth-layout";
import { RegisterForm } from "../components/auth/login-form"; import { RegisterForm } from "../components/auth/login-form";
import { withSession } from "../components/user-provider"; import { getStaticTranslations } from "../utils/with-page-translations";
import { withPageTranslations } from "../utils/with-page-translations";
const Page: NextPage = () => { const Page: NextPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
return ( return (
<AuthLayout> <AuthLayout>
@ -19,13 +18,13 @@ const Page: NextPage = () => {
</Head> </Head>
<RegisterForm <RegisterForm
onRegistered={() => { onRegistered={() => {
router.replace("/polls"); router.replace("/");
}} }}
/> />
</AuthLayout> </AuthLayout>
); );
}; };
export const getServerSideProps = withSessionSsr(withPageTranslations()); export const getStaticProps = getStaticTranslations;
export default withSession(Page); export default Page;

View file

@ -6,17 +6,25 @@ const getVercelUrl = () => {
: null; : null;
}; };
function joinPath(baseUrl: string, subpath = "") {
if (subpath) {
const url = new URL(subpath, baseUrl);
return url.href;
}
return baseUrl;
}
export function absoluteUrl(subpath = "") { export function absoluteUrl(subpath = "") {
const baseUrl = const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_BASE_URL ??
getVercelUrl() ?? getVercelUrl() ??
`http://localhost:${port}`; `http://localhost:${port}`;
const url = new URL(subpath, baseUrl);
return url.href; return joinPath(baseUrl, subpath);
} }
export function shortUrl(subpath = "") { export function shortUrl(subpath = "") {
const baseUrl = process.env.NEXT_PUBLIC_SHORT_BASE_URL ?? absoluteUrl(); const baseUrl = process.env.NEXT_PUBLIC_SHORT_BASE_URL ?? absoluteUrl();
const url = new URL(subpath, baseUrl); return joinPath(baseUrl, subpath);
return url.href;
} }