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", "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>",

View file

@ -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();

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 { 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}`),
}, },
}); });

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 { 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");
}); });
}); });

View file

@ -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 ? (
<> <>

View file

@ -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&apos;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 &rarr;</Link> <Button href={pollUrl}>View on {getDomain()}</Button>
</Section> </Text>
</EmailLayout> </EmailLayout>
); );
}; };

View file

@ -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)}
/>
);
};

View file

@ -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());

View file

@ -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>
); );
}; };

View file

@ -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&apos;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 &rarr; 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>
); );
}; };

View file

@ -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",

View file

@ -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&apos;t you, please ignore this email.
{removeProtocalFromUrl(absoluteUrl())}
</Link>
. If this wasn&apos;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 &rarr;
</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 &rarr;</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>
); );
}; };

View file

@ -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&apos;re receiving this email because a request was made to You&apos;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&apos;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>
); );
}; };

View file

@ -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 &rarr; </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>
); );
}; };

View file

@ -1 +1,2 @@
export * from "./src/absolute-url"; export * from "./src/absolute-url";
export * from "./src/prevent-widows";

View 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");
}