Allow users to log in with magic link (#553)

This commit is contained in:
Luke Vella 2023-03-13 15:49:12 +00:00 committed by GitHub
parent 5b78093c6f
commit 2cf9ad467c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 425 additions and 186 deletions

View file

@ -125,7 +125,7 @@
"specifyTimesDescription": "Include start and end times for each option",
"stepSummary": "Step {{current}} of {{total}}",
"sunday": "Sunday",
"redirect": "<a>Click here</a> if you are not redirect automatically…",
"redirect": "<a>Click here</a> if you are not redirected automatically…",
"timeFormat": "Time format:",
"timeZone": "Time Zone:",
"title": "Title",
@ -137,6 +137,8 @@
"user": "User",
"userAlreadyExists": "A user with that email already exists",
"userNotFound": "A user with that email doesn't exist",
"loginSuccessful": "You're logged in! Please wait while you are redirected…",
"userDoesNotExist": "The requested user was not found",
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
"verificationCodePlaceholder": "Enter your 6-digit code",
"verificationCodeSent": "A verification code has been sent to <b>{{email}}</b> <a>Change</a>",

View file

@ -276,6 +276,7 @@ export const LoginForm: React.FunctionComponent<{
const { register, handleSubmit, getValues, formState, setError } = useForm<{
email: string;
}>();
const requestLogin = trpc.auth.requestLogin.useMutation();
const authenticateLogin = trpc.auth.authenticateLogin.useMutation();

View file

@ -0,0 +1,138 @@
import { prisma } from "@rallly/database";
import clsx from "clsx";
import { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import React from "react";
import CheckCircle from "@/components/icons/check-circle.svg";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { Spinner } from "@/components/spinner";
import {
composeGetServerSideProps,
decryptToken,
LoginTokenPayload,
withSessionSsr,
} from "@/utils/auth";
import { withPageTranslations } from "@/utils/with-page-translations";
const defaultRedirectPath = "/profile";
const redirectToInvalidToken = {
redirect: {
destination: "/auth/invalid-token",
permanent: false,
},
};
const Redirect = () => {
const { t } = useTranslation("app");
const [enabled, setEnabled] = React.useState(false);
const router = useRouter();
React.useEffect(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
setTimeout(() => {
router.replace(defaultRedirectPath);
}, 3000);
}, [router]);
return (
<div className="space-y-2">
<div className="flex h-10 items-center justify-center gap-4">
{enabled ? (
<CheckCircle
className={clsx("animate-popIn h-10 text-green-400", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-slate-800">{t("loginSuccessful")}</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={defaultRedirectPath} />,
}}
/>
</div>
</div>
);
};
export const Page = (
props:
| {
success: true;
name: string;
}
| {
success: false;
errorCode: "userNotFound";
},
) => {
const { t } = useTranslation("app");
return (
<AuthLayout title={t("login")}>
{props.success ? (
<Redirect />
) : (
<Trans t={t} i18nKey="userDoesNotExist" />
)}
</AuthLayout>
);
};
export default Page;
export const getServerSideProps: GetServerSideProps = composeGetServerSideProps(
withPageTranslations(["app"]),
withSessionSsr(async (ctx) => {
const token = ctx.query.token as string;
if (!token) {
// token is missing
return redirectToInvalidToken;
}
const payload = await decryptToken<LoginTokenPayload>(token);
if (!payload) {
// token is invalid or expired
return redirectToInvalidToken;
}
const user = await prisma.user.findFirst({
select: {
id: true,
},
where: { id: payload.userId },
});
if (!user) {
// user does not exist
return {
props: {
success: false,
errorCode: "userNotFound",
},
};
}
ctx.req.session.user = { id: user.id, isGuest: false };
await ctx.req.session.save();
return {
props: {
success: true,
},
};
}),
);

View file

@ -3,6 +3,8 @@ import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { absoluteUrl } from "@/utils/absolute-url";
import {
createToken,
decryptToken,
@ -130,6 +132,7 @@ export const auth = router({
props: {
name: user.name,
code,
magicLink: absoluteUrl(`/auth/login?token=${token}`),
},
});

View file

@ -1,20 +1,32 @@
import { expect, Page, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { prisma } from "@rallly/database";
import { load } from "cheerio";
import smtpTester from "smtp-tester";
const testUserEmail = "test@example.com";
let mailServer: smtpTester.SmtpTester;
/**
* Get the 6-digit code from the email
* @returns 6-digit code
*/
const getCode = async () => {
const { email } = await mailServer.captureOne(testUserEmail, {
wait: 5000,
});
const $ = load(email.html);
return $("#code").text().trim();
};
test.describe.serial(() => {
let mailServer: smtpTester.SmtpTester;
test.beforeAll(() => {
mailServer = smtpTester.init(4025);
});
test.afterAll(async () => {
try {
await prisma.user.delete({
await prisma.user.deleteMany({
where: {
email: testUserEmail,
},
@ -25,93 +37,107 @@ test.describe.serial(() => {
mailServer.stop();
});
/**
* Get the 6-digit code from the email
* @returns 6-digit code
*/
const getCode = async () => {
const { email } = await mailServer.captureOne(testUserEmail, {
wait: 5000,
test.describe("new user", () => {
test("shows that user doesn't exist yet", async ({ page }) => {
await page.goto("/login");
// your login page test logic
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
await page.getByText("Continue").click();
// Make sure the user doesn't exist yet and that logging in is not possible
await expect(
page.getByText("A user with that email doesn't exist"),
).toBeVisible();
});
const $ = load(email.html);
test("user registration", async ({ page }) => {
await page.goto("/register");
return $("#code").text().trim();
};
await page.getByText("Create an account").waitFor();
test("shows that user doesn't exist yet", async ({ page }) => {
await page.goto("/login");
await page.getByPlaceholder("Jessie Smith").type("Test User");
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
// your login page test logic
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
await page.click("text=Continue");
await page.getByText("Continue").click();
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
// Make sure the user doesn't exist yet and that logging in is not possible
await expect(
page.getByText("A user with that email doesn't exist"),
).toBeVisible();
codeInput.waitFor({ state: "visible" });
const code = await getCode();
await codeInput.type(code);
await page.getByText("Continue").click();
await expect(page.getByText("Your details")).toBeVisible();
});
});
test("user registration", async ({ page }) => {
await page.goto("/register");
test.describe("existing user", () => {
test("can't register with the same email", async ({ page }) => {
await page.goto("/register");
await page.getByText("Create an account").waitFor();
await page.getByText("Create an account").waitFor();
await page.getByPlaceholder("Jessie Smith").type("Test User");
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
await page.getByPlaceholder("Jessie Smith").type("Test User");
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
await page.click("text=Continue");
await page.click("text=Continue");
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
await expect(
page.getByText("A user with that email already exists"),
).toBeVisible();
});
codeInput.waitFor({ state: "visible" });
test.describe("login", () => {
test.afterEach(async ({ page }) => {
await page.goto("/logout");
});
});
const code = await getCode();
test("can login with magic link", async ({ page }) => {
await page.goto("/login");
await codeInput.type(code);
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
await page.getByText("Continue").click();
await page.getByText("Continue").click();
await expect(page.getByText("Your details")).toBeVisible();
});
const { email } = await mailServer.captureOne(testUserEmail, {
wait: 5000,
});
test("can't register with the same email", async ({ page }) => {
await page.goto("/register");
const $ = load(email.html);
await page.getByText("Create an account").waitFor();
const magicLink = $("#magicLink").attr("href");
await page.getByPlaceholder("Jessie Smith").type("Test User");
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
if (!magicLink) {
throw new Error("Magic link not found");
}
await page.click("text=Continue");
await page.goto(magicLink);
await expect(
page.getByText("A user with that email already exists"),
).toBeVisible();
});
page.getByText("Click here").click();
const login = async (page: Page) => {
await page.goto("/login");
await expect(page.getByText("Your details")).toBeVisible();
});
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
test("can login with verification code", async ({ page }) => {
await page.goto("/login");
await page.getByText("Continue").click();
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
const code = await getCode();
await page.getByText("Continue").click();
await page.getByPlaceholder("Enter your 6-digit code").type(code);
const code = await getCode();
await page.getByText("Continue").click();
};
await page.getByPlaceholder("Enter your 6-digit code").type(code);
test("user login", async ({ page }) => {
await login(page);
await expect(page.getByText("Your details")).toBeVisible();
});
await page.getByText("Continue").click();
test("logged in user can't access login page", async ({ page }) => {
await login(page);
await expect(page).toHaveURL("/profile");
await expect(page.getByText("Your details")).toBeVisible();
});
});
});