mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-30 02:36:30 +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",
|
"specifyTimesDescription": "Include start and end times for each option",
|
||||||
"stepSummary": "Step {{current}} of {{total}}",
|
"stepSummary": "Step {{current}} of {{total}}",
|
||||||
"sunday": "Sunday",
|
"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:",
|
"timeFormat": "Time format:",
|
||||||
"timeZone": "Time Zone:",
|
"timeZone": "Time Zone:",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
@ -137,6 +137,8 @@
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"userAlreadyExists": "A user with that email already exists",
|
"userAlreadyExists": "A user with that email already exists",
|
||||||
"userNotFound": "A user with that email doesn't exist",
|
"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.",
|
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
|
||||||
"verificationCodePlaceholder": "Enter your 6-digit code",
|
"verificationCodePlaceholder": "Enter your 6-digit code",
|
||||||
"verificationCodeSent": "A verification code has been sent to <b>{{email}}</b> <a>Change</a>",
|
"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<{
|
const { register, handleSubmit, getValues, formState, setError } = useForm<{
|
||||||
email: string;
|
email: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const requestLogin = trpc.auth.requestLogin.useMutation();
|
const requestLogin = trpc.auth.requestLogin.useMutation();
|
||||||
const authenticateLogin = trpc.auth.authenticateLogin.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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { absoluteUrl } from "@/utils/absolute-url";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createToken,
|
createToken,
|
||||||
decryptToken,
|
decryptToken,
|
||||||
|
@ -130,6 +132,7 @@ export const auth = router({
|
||||||
props: {
|
props: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
code,
|
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 { prisma } from "@rallly/database";
|
||||||
import { load } from "cheerio";
|
import { load } from "cheerio";
|
||||||
import smtpTester from "smtp-tester";
|
import smtpTester from "smtp-tester";
|
||||||
|
|
||||||
const testUserEmail = "test@example.com";
|
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(() => {
|
test.describe.serial(() => {
|
||||||
let mailServer: smtpTester.SmtpTester;
|
|
||||||
|
|
||||||
test.beforeAll(() => {
|
test.beforeAll(() => {
|
||||||
mailServer = smtpTester.init(4025);
|
mailServer = smtpTester.init(4025);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
try {
|
try {
|
||||||
await prisma.user.delete({
|
await prisma.user.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
email: testUserEmail,
|
email: testUserEmail,
|
||||||
},
|
},
|
||||||
|
@ -25,93 +37,107 @@ test.describe.serial(() => {
|
||||||
mailServer.stop();
|
mailServer.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
test.describe("new user", () => {
|
||||||
* Get the 6-digit code from the email
|
test("shows that user doesn't exist yet", async ({ page }) => {
|
||||||
* @returns 6-digit code
|
await page.goto("/login");
|
||||||
*/
|
|
||||||
const getCode = async () => {
|
// your login page test logic
|
||||||
const { email } = await mailServer.captureOne(testUserEmail, {
|
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
|
||||||
wait: 5000,
|
|
||||||
|
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.getByPlaceholder("Jessie Smith").type("Test User");
|
||||||
await page.goto("/login");
|
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
|
||||||
|
|
||||||
// your login page test logic
|
await page.click("text=Continue");
|
||||||
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
|
|
||||||
|
|
||||||
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
|
codeInput.waitFor({ state: "visible" });
|
||||||
await expect(
|
|
||||||
page.getByText("A user with that email doesn't exist"),
|
const code = await getCode();
|
||||||
).toBeVisible();
|
|
||||||
|
await codeInput.type(code);
|
||||||
|
|
||||||
|
await page.getByText("Continue").click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Your details")).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user registration", async ({ page }) => {
|
test.describe("existing user", () => {
|
||||||
await page.goto("/register");
|
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").type("Test User");
|
||||||
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
|
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 }) => {
|
const $ = load(email.html);
|
||||||
await page.goto("/register");
|
|
||||||
|
|
||||||
await page.getByText("Create an account").waitFor();
|
const magicLink = $("#magicLink").attr("href");
|
||||||
|
|
||||||
await page.getByPlaceholder("Jessie Smith").type("Test User");
|
if (!magicLink) {
|
||||||
await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
|
throw new Error("Magic link not found");
|
||||||
|
}
|
||||||
|
|
||||||
await page.click("text=Continue");
|
await page.goto(magicLink);
|
||||||
|
|
||||||
await expect(
|
page.getByText("Click here").click();
|
||||||
page.getByText("A user with that email already exists"),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
const login = async (page: Page) => {
|
await expect(page.getByText("Your details")).toBeVisible();
|
||||||
await page.goto("/login");
|
});
|
||||||
|
|
||||||
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 page.getByText("Continue").click();
|
||||||
await login(page);
|
|
||||||
await expect(page.getByText("Your details")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logged in user can't access login page", async ({ page }) => {
|
await expect(page.getByText("Your details")).toBeVisible();
|
||||||
await login(page);
|
});
|
||||||
await expect(page).toHaveURL("/profile");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -27,6 +27,7 @@ export const EmailLayout = ({
|
||||||
children,
|
children,
|
||||||
footNote,
|
footNote,
|
||||||
}: React.PropsWithChildren<EmailLayoutProps>) => {
|
}: React.PropsWithChildren<EmailLayoutProps>) => {
|
||||||
|
const firstName = recipientName.split(" ")[0];
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
|
@ -127,12 +128,12 @@ export const EmailLayout = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Body className="bg-white px-3 py-6">
|
<Body className="bg-white px-3 py-6">
|
||||||
<Container className="max-w-lg">
|
<Container className="max-w-xl">
|
||||||
<Section className="mb-4">
|
<Section className="my-4">
|
||||||
<Img src={absoluteUrl("/logo.png")} alt="Rallly" width={128} />
|
<Img src={absoluteUrl("/logo.png")} alt="Rallly" width={128} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
<Text>Hi {recipientName},</Text>
|
<Text>Hi {firstName},</Text>
|
||||||
{children}
|
{children}
|
||||||
{footNote ? (
|
{footNote ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { EmailLayout } from "./email-layout";
|
import { EmailLayout } from "./email-layout";
|
||||||
import { Link, Section } from "./styled-components";
|
import { Button, Card, Link, Text } from "./styled-components";
|
||||||
|
import { getDomain } from "./utils";
|
||||||
|
|
||||||
export interface NotificationBaseProps {
|
export interface NotificationBaseProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -14,7 +15,6 @@ export interface NotificationEmailProps extends NotificationBaseProps {
|
||||||
|
|
||||||
export const NotificationEmail = ({
|
export const NotificationEmail = ({
|
||||||
name,
|
name,
|
||||||
title,
|
|
||||||
pollUrl,
|
pollUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
preview,
|
preview,
|
||||||
|
@ -25,21 +25,18 @@ export const NotificationEmail = ({
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
footNote={
|
footNote={
|
||||||
<>
|
<>
|
||||||
You're receiving this email because notifications are enabled for{" "}
|
If you would like to stop receiving updates you can{" "}
|
||||||
<strong>{title}</strong>. If you want to stop receiving emails about
|
|
||||||
this event you can{" "}
|
|
||||||
<Link className="whitespace-nowrap" href={unsubscribeUrl}>
|
<Link className="whitespace-nowrap" href={unsubscribeUrl}>
|
||||||
turn notifications off
|
turn notifications off
|
||||||
</Link>
|
</Link>
|
||||||
.
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Section>
|
<Text>
|
||||||
<Link href={pollUrl}>Go to poll →</Link>
|
<Button href={pollUrl}>View on {getDomain()}</Button>
|
||||||
</Section>
|
</Text>
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { absoluteUrl } from "@rallly/utils";
|
||||||
import {
|
import {
|
||||||
Button as UnstyledButton,
|
Button as UnstyledButton,
|
||||||
ButtonProps,
|
ButtonProps,
|
||||||
|
@ -11,18 +12,29 @@ import {
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const Text = (props: TextProps) => {
|
import { getDomain } from "./utils";
|
||||||
|
|
||||||
|
export const Text = (
|
||||||
|
props: TextProps & { light?: boolean; small?: boolean },
|
||||||
|
) => {
|
||||||
|
const { light, small, className, ...forwardProps } = props;
|
||||||
return (
|
return (
|
||||||
<UnstyledText
|
<UnstyledText
|
||||||
{...props}
|
{...forwardProps}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"my-4 font-sans text-base text-slate-800",
|
"my-4 font-sans ",
|
||||||
props.className,
|
{ "text-base": !small, "text-sm": small },
|
||||||
|
{ "text-slate-800": !light, "text-slate-500": light },
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Domain = () => {
|
||||||
|
return <Link href={absoluteUrl()}>{getDomain()}</Link>;
|
||||||
|
};
|
||||||
|
|
||||||
export const Button = (props: ButtonProps) => {
|
export const Button = (props: ButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
|
@ -47,18 +59,36 @@ export const Link = (props: LinkProps) => {
|
||||||
export const Heading = (
|
export const Heading = (
|
||||||
props: React.ComponentProps<typeof UnstyledHeading>,
|
props: React.ComponentProps<typeof UnstyledHeading>,
|
||||||
) => {
|
) => {
|
||||||
|
const { as = "h3" } = props;
|
||||||
return (
|
return (
|
||||||
<UnstyledHeading
|
<UnstyledHeading
|
||||||
{...props}
|
{...props}
|
||||||
as={props.as || "h3"}
|
as={as}
|
||||||
className={clsx("my-4 font-sans text-slate-800", props.className)}
|
className={clsx(
|
||||||
|
"mt-4 mb-2 font-sans font-semibold text-slate-800",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubHeadingText = (props: TextProps) => {
|
||||||
|
const { className, ...forwardProps } = props;
|
||||||
|
return (
|
||||||
|
<UnstyledText
|
||||||
|
{...forwardProps}
|
||||||
|
className={clsx(
|
||||||
|
"mb-4 mt-2 font-sans text-base text-slate-800",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Section = (props: SectionProps) => {
|
export const Section = (props: SectionProps) => {
|
||||||
|
const { className, ...forwardProps } = props;
|
||||||
return (
|
return (
|
||||||
<UnstyledSection {...props} className={clsx("my-4", props.className)} />
|
<UnstyledSection {...forwardProps} className={clsx("my-4", className)} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -70,3 +100,12 @@ export const SmallText = (props: TextProps) => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Card = (props: SectionProps) => {
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
{...props}
|
||||||
|
className={clsx("rounded bg-gray-50 px-4", props.className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { absoluteUrl } from "@rallly/utils";
|
||||||
|
|
||||||
export const removeProtocalFromUrl = (url: string) => {
|
export const removeProtocalFromUrl = (url: string) => {
|
||||||
return url.replace(/(^\w+:|^)\/\//, "");
|
return url.replace(/(^\w+:|^)\/\//, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDomain = () => removeProtocalFromUrl(absoluteUrl());
|
||||||
|
|
|
@ -1,20 +1,27 @@
|
||||||
import { absoluteUrl } from "@rallly/utils";
|
import { absoluteUrl } from "@rallly/utils";
|
||||||
|
import { Hr } from "@react-email/components";
|
||||||
|
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import { Heading, Link, Text } from "./components/styled-components";
|
import {
|
||||||
import { removeProtocalFromUrl } from "./components/utils";
|
Button,
|
||||||
|
Heading,
|
||||||
|
Link,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "./components/styled-components";
|
||||||
|
import { getDomain, removeProtocalFromUrl } from "./components/utils";
|
||||||
|
|
||||||
interface LoginEmailProps {
|
interface LoginEmailProps {
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
// magicLink: string;
|
magicLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginEmail = ({
|
export const LoginEmail = ({
|
||||||
name = "Guest",
|
name = "Guest",
|
||||||
code = "123456",
|
code = "123456",
|
||||||
}: // magicLink = "https://rallly.co",
|
magicLink = "https://rallly.co",
|
||||||
LoginEmailProps) => {
|
}: LoginEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
footNote={
|
footNote={
|
||||||
|
@ -30,24 +37,19 @@ LoginEmailProps) => {
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
preview={`Your 6-digit code: ${code}`}
|
preview={`Your 6-digit code: ${code}`}
|
||||||
>
|
>
|
||||||
<Text>Your 6-digit code is:</Text>
|
<Text>Use this link to log in on this device.</Text>
|
||||||
<Heading as="h1" className="font-sans tracking-widest" id="code">
|
<Button href={magicLink} id="magicLink">
|
||||||
{code}
|
Log in to {getDomain()}
|
||||||
</Heading>
|
</Button>
|
||||||
<Text>
|
<Text light={true}>This link is valid for 15 minutes</Text>
|
||||||
Use this code to complete the verification process on{" "}
|
<Section>
|
||||||
<Link href={absoluteUrl()}>{removeProtocalFromUrl(absoluteUrl())}</Link>
|
<Text>
|
||||||
</Text>
|
Alternatively, you can enter this 6-digit verification code directly.
|
||||||
<Text>
|
</Text>
|
||||||
<span className="text-slate-500">
|
<Heading as="h1" className="tracking-widest" id="code">
|
||||||
This code is valid for 15 minutes
|
{code}
|
||||||
</span>
|
</Heading>
|
||||||
</Text>
|
</Section>
|
||||||
{/* <Heading>Magic link</Heading>
|
|
||||||
<Text>
|
|
||||||
Alternatively, you can login by using this{" "}
|
|
||||||
<Link href={magicLink}>magic link ✨</Link>
|
|
||||||
</Text> */}
|
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import { Button, Section, Text } from "./components/styled-components";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Domain,
|
||||||
|
Heading,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "./components/styled-components";
|
||||||
|
import { getDomain } from "./components/utils";
|
||||||
|
|
||||||
interface NewParticipantConfirmationEmailProps {
|
interface NewParticipantConfirmationEmailProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -8,31 +16,32 @@ interface NewParticipantConfirmationEmailProps {
|
||||||
}
|
}
|
||||||
export const NewParticipantConfirmationEmail = ({
|
export const NewParticipantConfirmationEmail = ({
|
||||||
title = "Untitled Poll",
|
title = "Untitled Poll",
|
||||||
name = "Guest",
|
name = "John",
|
||||||
editSubmissionUrl = "https://rallly.co",
|
editSubmissionUrl = "https://rallly.co",
|
||||||
}: NewParticipantConfirmationEmailProps) => {
|
}: NewParticipantConfirmationEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
footNote={
|
footNote={
|
||||||
<>You are receiving this email because a response was submitting </>
|
<>
|
||||||
|
You are receiving this email because a response was submitted on{" "}
|
||||||
|
<Domain />. If this wasn't you, please ignore this email.
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
preview="To edit your response use the link below"
|
preview="To edit your response use the link below"
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
Thank you for submitting your availability for <strong>{title}</strong>.
|
Thank you for responding to <strong>{title}</strong>.
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
While the poll is still open you can change your response using the link
|
||||||
|
below.
|
||||||
</Text>
|
</Text>
|
||||||
<Text>To review your response, use the link below:</Text>
|
|
||||||
<Section>
|
<Section>
|
||||||
<Button id="editSubmissionUrl" href={editSubmissionUrl}>
|
<Button id="editSubmissionUrl" href={editSubmissionUrl}>
|
||||||
Review response →
|
Review response on {getDomain()}
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
<Text>
|
|
||||||
<em className="text-slate-500">
|
|
||||||
Keep this link safe and do not share it with others.
|
|
||||||
</em>
|
|
||||||
</Text>
|
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,7 +8,7 @@ export interface NewParticipantEmailProps extends NotificationBaseProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NewParticipantEmail = ({
|
export const NewParticipantEmail = ({
|
||||||
name = "Guest",
|
name = "John",
|
||||||
title = "Untitled Poll",
|
title = "Untitled Poll",
|
||||||
participantName = "Someone",
|
participantName = "Someone",
|
||||||
pollUrl = "https://rallly.co",
|
pollUrl = "https://rallly.co",
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
import { absoluteUrl } from "@rallly/utils";
|
import { absoluteUrl, preventWidows } from "@rallly/utils";
|
||||||
|
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import { Heading, Link, Section, Text } from "./components/styled-components";
|
import {
|
||||||
import { removeProtocalFromUrl } from "./components/utils";
|
Button,
|
||||||
|
Card,
|
||||||
|
Heading,
|
||||||
|
Link,
|
||||||
|
Section,
|
||||||
|
SubHeadingText,
|
||||||
|
Text,
|
||||||
|
} from "./components/styled-components";
|
||||||
|
import { getDomain } from "./components/utils";
|
||||||
|
|
||||||
export interface NewPollEmailProps {
|
export interface NewPollEmailProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -22,7 +30,7 @@ const ShareLink = ({
|
||||||
participantLink: string;
|
participantLink: string;
|
||||||
}>) => {
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Button
|
||||||
href={`mailto:?subject=${encodeURIComponent(
|
href={`mailto:?subject=${encodeURIComponent(
|
||||||
`Availability for ${title}`,
|
`Availability for ${title}`,
|
||||||
)}&body=${encodeURIComponent(
|
)}&body=${encodeURIComponent(
|
||||||
|
@ -30,23 +38,13 @@ const ShareLink = ({
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Button>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LinkContainer = (props: { link: string }) => {
|
|
||||||
return (
|
|
||||||
<Section className="rounded bg-gray-50 p-4">
|
|
||||||
<Link href={props.link} className="font-mono">
|
|
||||||
{props.link}
|
|
||||||
</Link>
|
|
||||||
</Section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NewPollEmail = ({
|
export const NewPollEmail = ({
|
||||||
title = "Untitled Poll",
|
title = "Untitled Poll",
|
||||||
name = "Guest",
|
name = "John",
|
||||||
adminLink = "https://rallly.co/admin/abcdefg123",
|
adminLink = "https://rallly.co/admin/abcdefg123",
|
||||||
participantLink = "https://rallly.co/p/wxyz9876",
|
participantLink = "https://rallly.co/p/wxyz9876",
|
||||||
}: NewPollEmailProps) => {
|
}: NewPollEmailProps) => {
|
||||||
|
@ -55,39 +53,51 @@ export const NewPollEmail = ({
|
||||||
footNote={
|
footNote={
|
||||||
<>
|
<>
|
||||||
You are receiving this email because a new poll was created with this
|
You are receiving this email because a new poll was created with this
|
||||||
email address on{" "}
|
email address on <Link href={absoluteUrl()}>{getDomain()}</Link>. If
|
||||||
<Link href={absoluteUrl()}>
|
this wasn't you, please ignore this email.
|
||||||
{removeProtocalFromUrl(absoluteUrl())}
|
|
||||||
</Link>
|
|
||||||
. If this wasn't you, please ignore this email.
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
preview="Share your participant link to start collecting responses."
|
preview="Share your participant link to start collecting responses."
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
Your new poll is ready! Now lets find a date for{" "}
|
Your poll is live! Here are two links you will need to manage your poll.
|
||||||
<strong>{title}</strong>.
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
Copy this link and share it with your participants to start collecting
|
|
||||||
responses.
|
|
||||||
</Text>
|
|
||||||
<LinkContainer link={participantLink} />
|
|
||||||
<Text>
|
|
||||||
<ShareLink title={title} name={name} participantLink={participantLink}>
|
|
||||||
Share via email →
|
|
||||||
</ShareLink>
|
|
||||||
</Text>
|
|
||||||
<Heading>Your secret link</Heading>
|
|
||||||
<Text>
|
|
||||||
Use this link to access the admin page where you can view and edit your
|
|
||||||
poll.
|
|
||||||
</Text>
|
|
||||||
<LinkContainer link={adminLink} />
|
|
||||||
<Text>
|
|
||||||
<Link href={adminLink}>Go to admin page →</Link>
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Card>
|
||||||
|
<Heading>Admin link</Heading>
|
||||||
|
<SubHeadingText>
|
||||||
|
Use this link to view results and make changes to your poll.
|
||||||
|
</SubHeadingText>
|
||||||
|
<Text className="rounded bg-white px-4 py-3">
|
||||||
|
<Link href={adminLink} className="font-mono">
|
||||||
|
{adminLink}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Button href={adminLink}>Go to admin page</Button>
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Heading>Participant link</Heading>
|
||||||
|
<SubHeadingText>
|
||||||
|
Copy this link and share it with your participants to start collecting
|
||||||
|
responses.
|
||||||
|
</SubHeadingText>
|
||||||
|
<Text className="rounded bg-white px-4 py-3">
|
||||||
|
<Link href={participantLink} className="font-mono">
|
||||||
|
{participantLink}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<ShareLink
|
||||||
|
title={title}
|
||||||
|
name={name}
|
||||||
|
participantLink={participantLink}
|
||||||
|
>
|
||||||
|
Share via email
|
||||||
|
</ShareLink>
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { absoluteUrl } from "@rallly/utils";
|
import { absoluteUrl } from "@rallly/utils";
|
||||||
import { Heading } from "@react-email/heading";
|
|
||||||
|
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import { Link, Text } from "./components/styled-components";
|
import { Domain, Heading, Link, Text } from "./components/styled-components";
|
||||||
import { removeProtocalFromUrl } from "./components/utils";
|
import { getDomain } from "./components/utils";
|
||||||
|
|
||||||
interface RegisterEmailProps {
|
interface RegisterEmailProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -11,7 +10,7 @@ interface RegisterEmailProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RegisterEmail = ({
|
export const RegisterEmail = ({
|
||||||
name = "Guest",
|
name = "John",
|
||||||
code = "123456",
|
code = "123456",
|
||||||
}: RegisterEmailProps) => {
|
}: RegisterEmailProps) => {
|
||||||
return (
|
return (
|
||||||
|
@ -21,25 +20,22 @@ export const RegisterEmail = ({
|
||||||
You're receiving this email because a request was made to
|
You're receiving this email because a request was made to
|
||||||
register an account on{" "}
|
register an account on{" "}
|
||||||
<Link className="text-primary-500" href={absoluteUrl()}>
|
<Link className="text-primary-500" href={absoluteUrl()}>
|
||||||
{removeProtocalFromUrl(absoluteUrl())}
|
{getDomain()}
|
||||||
</Link>
|
</Link>
|
||||||
.
|
. If this wasn't you, please ignore this email.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
preview={`Your 6-digit code is: ${code}`}
|
preview={`Your 6-digit code is: ${code}`}
|
||||||
>
|
>
|
||||||
<Text>Your 6-digit code is:</Text>
|
<Text>
|
||||||
<Heading className="font-sans tracking-widest" id="code">
|
Use this code to complete the verification process on <Domain />
|
||||||
|
</Text>
|
||||||
|
<Heading>Your 6-digit code is:</Heading>
|
||||||
|
<Heading as="h1" className="font-sans tracking-widest" id="code">
|
||||||
{code}
|
{code}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text>
|
<Text light={true}>This code is valid for 15 minutes</Text>
|
||||||
Use this code to complete the verification process on{" "}
|
|
||||||
<Link href={absoluteUrl()}>{removeProtocalFromUrl(absoluteUrl())}</Link>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<span className="text-gray-500">This code is valid for 15 minutes</span>
|
|
||||||
</Text>
|
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { EmailLayout } from "./components/email-layout";
|
import { EmailLayout } from "./components/email-layout";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Heading,
|
||||||
Link,
|
Link,
|
||||||
Section,
|
Section,
|
||||||
SmallText,
|
SmallText,
|
||||||
|
@ -16,14 +18,14 @@ type EnableNotificationsEmailProps = {
|
||||||
|
|
||||||
export const EnableNotificationsEmail = ({
|
export const EnableNotificationsEmail = ({
|
||||||
title = "Untitled Poll",
|
title = "Untitled Poll",
|
||||||
name = "Guest",
|
name = "John",
|
||||||
verificationLink = "https://rallly.co",
|
verificationLink = "https://rallly.co",
|
||||||
adminLink = "https://rallly.co",
|
adminLink = "https://rallly.co",
|
||||||
}: EnableNotificationsEmailProps) => {
|
}: EnableNotificationsEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
recipientName={name}
|
recipientName={name}
|
||||||
preview="Before we can send you notifications we need to verify your email"
|
preview="We need to verify your email address"
|
||||||
footNote={
|
footNote={
|
||||||
<>
|
<>
|
||||||
You are receiving this email because a request was made to enable
|
You are receiving this email because a request was made to enable
|
||||||
|
@ -32,18 +34,19 @@ export const EnableNotificationsEmail = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
Before we can send you notifications we need to verify your email.
|
Would you like to get notified when participants respond to{" "}
|
||||||
|
<strong>{title}</strong>?
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Card>
|
||||||
Click the button below to complete the email verification and enable
|
<Heading>Enable notifications</Heading>
|
||||||
notifications for <strong>{title}</strong>.
|
<Text>You will get an email when someone responds to the poll.</Text>
|
||||||
</Text>
|
<Section>
|
||||||
<Section>
|
<Button href={verificationLink} id="verifyEmailUrl">
|
||||||
<Button href={verificationLink} id="verifyEmailUrl">
|
Yes, enable notifications
|
||||||
Enable notifications →
|
</Button>
|
||||||
</Button>
|
</Section>
|
||||||
</Section>
|
<SmallText>The link will expire in 15 minutes.</SmallText>
|
||||||
<SmallText>The link will expire in 15 minutes.</SmallText>
|
</Card>
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./src/absolute-url";
|
export * from "./src/absolute-url";
|
||||||
|
export * from "./src/prevent-widows";
|
||||||
|
|
7
packages/utils/src/prevent-widows.ts
Normal file
7
packages/utils/src/prevent-widows.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export function preventWidows(text = "") {
|
||||||
|
if (text.split(" ").length < 3) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const index = text.lastIndexOf(" ");
|
||||||
|
return [text.substring(0, index), text.substring(index + 1)].join("\u00a0");
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue