mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-03 11:11:48 +02:00
✨ Allow users to log in with magic link (#553)
This commit is contained in:
parent
5b78093c6f
commit
2cf9ad467c
17 changed files with 425 additions and 186 deletions
|
@ -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>",
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
138
apps/web/src/pages/auth/login.tsx
Normal file
138
apps/web/src/pages/auth/login.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
|
@ -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}`),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue