mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-02 11:46:03 +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",
|
"hideScoresLabel": "Hide scores until after a participant has voted",
|
||||||
"authErrorTitle": "Login Error",
|
"authErrorTitle": "Login Error",
|
||||||
"authErrorDescription": "There was an error logging you in. Please try again.",
|
"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();
|
const { user } = useUser();
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild className="group">
|
<DropdownMenuTrigger data-testid="user-dropdown" asChild className="group">
|
||||||
<Button variant="ghost" className="rounded-full">
|
<Button variant="ghost" className="rounded-full">
|
||||||
<CurrentUserAvatar size="sm" className="-ml-1" />
|
<CurrentUserAvatar size="sm" className="-ml-1" />
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
|
|
@ -1,31 +1,127 @@
|
||||||
import { InfoIcon } from "@rallly/icons";
|
import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
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 {
|
import { StandardLayout } from "@/components/layouts/standard-layout";
|
||||||
PageDialog,
|
import { Logo } from "@/components/logo";
|
||||||
PageDialogDescription,
|
import { Skeleton } from "@/components/skeleton";
|
||||||
PageDialogFooter,
|
import { Trans } from "@/components/trans";
|
||||||
PageDialogHeader,
|
import { UserAvatar } from "@/components/user";
|
||||||
PageDialogTitle,
|
import { NextPageWithLayout } from "@/types";
|
||||||
} from "@/components/page-dialog";
|
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 (
|
return (
|
||||||
<PageDialog icon={InfoIcon}>
|
<div className="flex h-screen flex-col items-center justify-center gap-4 p-4">
|
||||||
<PageDialogHeader>
|
<Head>
|
||||||
<PageDialogTitle>Please login again</PageDialogTitle>
|
<title>{t("login")}</title>
|
||||||
<PageDialogDescription>
|
</Head>
|
||||||
This login was initiated with an older version of Rallly. Please login
|
<div className="mb-6">
|
||||||
again to continue. Sorry for the inconvinience.
|
<Logo />
|
||||||
</PageDialogDescription>
|
</div>
|
||||||
</PageDialogHeader>
|
|
||||||
<PageDialogFooter>
|
<div className="shadow-huge rounded-md bg-white p-4">
|
||||||
<Link href="/login" className="text-link">
|
<div className="w-48 text-center">
|
||||||
Login
|
<div className="mb-4 font-semibold">
|
||||||
</Link>
|
<Trans i18nKey="continueAs" defaults="Continue as" />
|
||||||
</PageDialogFooter>
|
</div>
|
||||||
</PageDialog>
|
<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;
|
export default Page;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
import { RegistrationTokenPayload } from "@rallly/backend";
|
import { RegistrationTokenPayload } from "@rallly/backend";
|
||||||
import { decryptToken } from "@rallly/backend/session";
|
import { decryptToken } from "@rallly/backend/session";
|
||||||
import { generateOtp, randomid } from "@rallly/backend/utils/nanoid";
|
import { generateOtp, randomid } from "@rallly/backend/utils/nanoid";
|
||||||
|
@ -16,14 +17,14 @@ import NextAuth, {
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import EmailProvider from "next-auth/providers/email";
|
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 { LegacyTokenProvider } from "@/utils/auth/legacy-token-provider";
|
||||||
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
|
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
|
||||||
import { emailClient } from "@/utils/emails";
|
import { emailClient } from "@/utils/emails";
|
||||||
|
|
||||||
const getAuthOptions = (...args: GetServerSessionParams) =>
|
const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
({
|
({
|
||||||
adapter: CustomPrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
secret: process.env.SECRET_PASSWORD,
|
secret: process.env.SECRET_PASSWORD,
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
|
@ -103,7 +104,9 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
subject: `${token} is your 6-digit code`,
|
subject: `${token} is your 6-digit code`,
|
||||||
props: {
|
props: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
magicLink: url,
|
magicLink: absoluteUrl("/auth/login", {
|
||||||
|
magicLink: url,
|
||||||
|
}),
|
||||||
code: token,
|
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";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
|
||||||
export const getStaticTranslations: GetStaticProps = async (ctx) => {
|
export const getStaticTranslations: GetStaticProps = async (ctx) => {
|
||||||
const locale = ctx.locale ?? "en";
|
|
||||||
return {
|
return {
|
||||||
props: {
|
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.goto(magicLink);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
await page.waitForURL("/polls");
|
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 }) => {
|
test("can login with verification code", async ({ page }) => {
|
||||||
|
@ -144,6 +150,10 @@ test.describe.serial(() => {
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
await page.waitForURL("/polls");
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { getSubscriptionStatus } from "../../utils/auth";
|
import { getSubscriptionStatus } from "../../utils/auth";
|
||||||
import { possiblyPublicProcedure, privateProcedure, router } from "../trpc";
|
import {
|
||||||
|
possiblyPublicProcedure,
|
||||||
|
privateProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
router,
|
||||||
|
} from "../trpc";
|
||||||
|
|
||||||
export const user = router({
|
export const user = router({
|
||||||
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
|
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(
|
subscription: possiblyPublicProcedure.query(
|
||||||
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
|
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
|
||||||
if (ctx.user.isGuest) {
|
if (ctx.user.isGuest) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue