mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 10:16:32 +02:00
♻️ Add intermediate step for magic link login (#910)
This commit is contained in:
parent
825f2ece2f
commit
4f1389c510
8 changed files with 167 additions and 70 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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");
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Reference in a new issue