♻️ Add intermediate step for magic link login (#910)

This commit is contained in:
Luke Vella 2023-10-24 17:42:50 +01:00 committed by GitHub
parent 825f2ece2f
commit 4f1389c510
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 167 additions and 70 deletions

View file

@ -222,5 +222,6 @@
"hideScoresLabel": "Hide scores until after a participant has voted",
"authErrorTitle": "Login Error",
"authErrorDescription": "There was an error logging you in. Please try again.",
"authErrorCta": "Go to login page"
"authErrorCta": "Go to login page",
"continueAs": "Continue as"
}

View file

@ -38,7 +38,7 @@ export const UserDropdown = () => {
const { user } = useUser();
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild className="group">
<DropdownMenuTrigger data-testid="user-dropdown" asChild className="group">
<Button variant="ghost" className="rounded-full">
<CurrentUserAvatar size="sm" className="-ml-1" />
<ChevronDown className="h-4 w-4" />

View file

@ -1,31 +1,127 @@
import { InfoIcon } from "@rallly/icons";
import Link from "next/link";
import { Button } from "@rallly/ui/button";
import { useMutation } from "@tanstack/react-query";
import { GetServerSideProps } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { z } from "zod";
import {
PageDialog,
PageDialogDescription,
PageDialogFooter,
PageDialogHeader,
PageDialogTitle,
} from "@/components/page-dialog";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { Logo } from "@/components/logo";
import { Skeleton } from "@/components/skeleton";
import { Trans } from "@/components/trans";
import { UserAvatar } from "@/components/user";
import { NextPageWithLayout } from "@/types";
import { trpc } from "@/utils/trpc/client";
import { getServerSideTranslations } from "@/utils/with-page-translations";
const Page = () => {
const params = z.object({
magicLink: z.string().url(),
});
const magicLinkParams = z.object({
email: z.string().email(),
token: z.string(),
});
type PageProps = { magicLink: string; email: string };
const Page: NextPageWithLayout<PageProps> = ({ magicLink, email }) => {
const session = useSession();
const magicLinkFetch = useMutation({
mutationFn: async () => {
const res = await fetch(magicLink);
return res;
},
onSuccess: (data) => {
session.update();
router.push(data.url);
},
});
const { data } = trpc.user.getByEmail.useQuery({ email });
const router = useRouter();
const { t } = useTranslation();
return (
<PageDialog icon={InfoIcon}>
<PageDialogHeader>
<PageDialogTitle>Please login again</PageDialogTitle>
<PageDialogDescription>
This login was initiated with an older version of Rallly. Please login
again to continue. Sorry for the inconvinience.
</PageDialogDescription>
</PageDialogHeader>
<PageDialogFooter>
<Link href="/login" className="text-link">
Login
</Link>
</PageDialogFooter>
</PageDialog>
<div className="flex h-screen flex-col items-center justify-center gap-4 p-4">
<Head>
<title>{t("login")}</title>
</Head>
<div className="mb-6">
<Logo />
</div>
<div className="shadow-huge rounded-md bg-white p-4">
<div className="w-48 text-center">
<div className="mb-4 font-semibold">
<Trans i18nKey="continueAs" defaults="Continue as" />
</div>
<div className="text-center">
<UserAvatar size="lg" name={data?.name} />
<div className="py-4 text-center">
<div className="mb-1 h-6 font-medium">
{data?.name ?? <Skeleton className="inline-block h-5 w-16" />}
</div>
<div className="text-muted-foreground h-5 truncate text-sm">
{data?.email ?? (
<Skeleton className="inline-block h-full w-20" />
)}
</div>
</div>
</div>
<Button
loading={magicLinkFetch.isLoading}
onClick={async () => {
await magicLinkFetch.mutateAsync();
}}
size="lg"
variant="primary"
className="mt-4 w-full"
>
<Trans i18nKey="continue" />
</Button>
</div>
</div>
</div>
);
};
Page.getLayout = (page) => (
<StandardLayout hideNav={true}>{page}</StandardLayout>
);
export const getServerSideProps: GetServerSideProps<PageProps> = async (
ctx,
) => {
const parse = params.safeParse(ctx.query);
if (!parse.success) {
return {
notFound: true,
};
}
const { magicLink } = parse.data;
const url = new URL(magicLink);
const parseMagicLink = magicLinkParams.safeParse(
Object.fromEntries(url.searchParams),
);
if (!parseMagicLink.success) {
return {
notFound: true,
};
}
return {
props: {
magicLink,
email: parseMagicLink.data.email,
...(await getServerSideTranslations(ctx)),
},
};
};
export default Page;

View file

@ -1,3 +1,4 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { RegistrationTokenPayload } from "@rallly/backend";
import { decryptToken } from "@rallly/backend/session";
import { generateOtp, randomid } from "@rallly/backend/utils/nanoid";
@ -16,14 +17,14 @@ import NextAuth, {
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import { CustomPrismaAdapter } from "@/utils/auth/custom-prisma-adapter";
import { absoluteUrl } from "@/utils/absolute-url";
import { LegacyTokenProvider } from "@/utils/auth/legacy-token-provider";
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
import { emailClient } from "@/utils/emails";
const getAuthOptions = (...args: GetServerSessionParams) =>
({
adapter: CustomPrismaAdapter(prisma),
adapter: PrismaAdapter(prisma),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
@ -103,7 +104,9 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
subject: `${token} is your 6-digit code`,
props: {
name: user.name,
magicLink: url,
magicLink: absoluteUrl("/auth/login", {
magicLink: url,
}),
code: token,
},
});

View file

@ -1,38 +0,0 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { Prisma, PrismaClient } from "@prisma/client";
import { Adapter } from "next-auth/adapters";
export function CustomPrismaAdapter(prisma: PrismaClient): Adapter {
const adapter = PrismaAdapter(prisma);
return {
...adapter,
// NOTE: Some users have inboxes with spam filters that check all links before they are delivered.
// This means the verification link will be used before the user gets it. To get around this, we
// avoid deleting the verification token when it is used. Instead we delete all verification tokens
// for an email address when a new verification token is created.
async createVerificationToken(data) {
await prisma.verificationToken.deleteMany({
where: { identifier: data.identifier },
});
const verificationToken = await prisma.verificationToken.create({
data,
});
return verificationToken;
},
async useVerificationToken(identifier_token) {
try {
const verificationToken = await prisma.verificationToken.findUnique({
where: { identifier_token },
});
return verificationToken;
} catch (error) {
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
if ((error as Prisma.PrismaClientKnownRequestError).code === "P2025")
return null;
throw error;
}
},
};
}

View file

@ -2,10 +2,17 @@ import { GetStaticProps } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
export const getStaticTranslations: GetStaticProps = async (ctx) => {
const locale = ctx.locale ?? "en";
return {
props: {
...(await serverSideTranslations(locale)),
...(await getServerSideTranslations(ctx)),
},
};
};
export const getServerSideTranslations = async ({
locale,
}: {
locale?: string;
}) => {
return await serverSideTranslations(locale ?? "en");
};

View file

@ -125,7 +125,13 @@ test.describe.serial(() => {
await page.goto(magicLink);
await page.getByRole("button", { name: "Continue" }).click();
await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible();
});
test("can login with verification code", async ({ page }) => {
@ -144,6 +150,10 @@ test.describe.serial(() => {
await page.getByRole("button", { name: "Continue" }).click();
await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible();
});
});
});

View file

@ -2,7 +2,12 @@ import { prisma } from "@rallly/database";
import { z } from "zod";
import { getSubscriptionStatus } from "../../utils/auth";
import { possiblyPublicProcedure, privateProcedure, router } from "../trpc";
import {
possiblyPublicProcedure,
privateProcedure,
publicProcedure,
router,
} from "../trpc";
export const user = router({
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
@ -20,6 +25,19 @@ export const user = router({
},
});
}),
getByEmail: publicProcedure
.input(z.object({ email: z.string() }))
.query(async ({ input }) => {
return await prisma.user.findUnique({
where: {
email: input.email,
},
select: {
name: true,
email: true,
},
});
}),
subscription: possiblyPublicProcedure.query(
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
if (ctx.user.isGuest) {