diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index ab25177a5..548cd7c14 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -125,7 +125,7 @@ "specifyTimesDescription": "Include start and end times for each option", "stepSummary": "Step {{current}} of {{total}}", "sunday": "Sunday", - "redirect": "Click here if you are not redirect automatically…", + "redirect": "Click here 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 {{email}} Change", diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx index f4ac55991..2977a7279 100644 --- a/apps/web/src/components/auth/login-form.tsx +++ b/apps/web/src/components/auth/login-form.tsx @@ -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(); diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx new file mode 100644 index 000000000..163f8befd --- /dev/null +++ b/apps/web/src/pages/auth/login.tsx @@ -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 ( +
+
+ {enabled ? ( + + ) : ( + + )} +
+
{t("loginSuccessful")}
+
+ , + }} + /> +
+
+ ); +}; +export const Page = ( + props: + | { + success: true; + name: string; + } + | { + success: false; + errorCode: "userNotFound"; + }, +) => { + const { t } = useTranslation("app"); + return ( + + {props.success ? ( + + ) : ( + + )} + + ); +}; + +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(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, + }, + }; + }), +); diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts index 09b054513..7131f90fd 100644 --- a/apps/web/src/server/routers/auth.ts +++ b/apps/web/src/server/routers/auth.ts @@ -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}`), }, }); diff --git a/apps/web/tests/authentication.spec.ts b/apps/web/tests/authentication.spec.ts index df9ed5154..03b858fbd 100644 --- a/apps/web/tests/authentication.spec.ts +++ b/apps/web/tests/authentication.spec.ts @@ -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(); + }); }); }); diff --git a/packages/emails/src/templates/components/email-layout.tsx b/packages/emails/src/templates/components/email-layout.tsx index a76252558..78a5e7e93 100644 --- a/packages/emails/src/templates/components/email-layout.tsx +++ b/packages/emails/src/templates/components/email-layout.tsx @@ -27,6 +27,7 @@ export const EmailLayout = ({ children, footNote, }: React.PropsWithChildren) => { + const firstName = recipientName.split(" ")[0]; return ( @@ -127,12 +128,12 @@ export const EmailLayout = ({ }} > - -
+ +
Rallly
- Hi {recipientName}, + Hi {firstName}, {children} {footNote ? ( <> diff --git a/packages/emails/src/templates/components/notification-email.tsx b/packages/emails/src/templates/components/notification-email.tsx index 4ed01b698..6c80699f7 100644 --- a/packages/emails/src/templates/components/notification-email.tsx +++ b/packages/emails/src/templates/components/notification-email.tsx @@ -1,5 +1,6 @@ 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 { name: string; @@ -14,7 +15,6 @@ export interface NotificationEmailProps extends NotificationBaseProps { export const NotificationEmail = ({ name, - title, pollUrl, unsubscribeUrl, preview, @@ -25,21 +25,18 @@ export const NotificationEmail = ({ recipientName={name} footNote={ <> - You're receiving this email because notifications are enabled for{" "} - {title}. If you want to stop receiving emails about - this event you can{" "} + If you would like to stop receiving updates you can{" "} turn notifications off - . } preview={preview} > {children} -
- Go to poll → -
+ + + ); }; diff --git a/packages/emails/src/templates/components/styled-components.tsx b/packages/emails/src/templates/components/styled-components.tsx index 613d4efaf..5f7760f93 100644 --- a/packages/emails/src/templates/components/styled-components.tsx +++ b/packages/emails/src/templates/components/styled-components.tsx @@ -1,3 +1,4 @@ +import { absoluteUrl } from "@rallly/utils"; import { Button as UnstyledButton, ButtonProps, @@ -11,18 +12,29 @@ import { } from "@react-email/components"; 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 ( ); }; +export const Domain = () => { + return {getDomain()}; +}; + export const Button = (props: ButtonProps) => { return ( { export const Heading = ( props: React.ComponentProps, ) => { + const { as = "h3" } = props; return ( + ); +}; + +export const SubHeadingText = (props: TextProps) => { + const { className, ...forwardProps } = props; + return ( + ); }; export const Section = (props: SectionProps) => { + const { className, ...forwardProps } = props; return ( - + ); }; @@ -70,3 +100,12 @@ export const SmallText = (props: TextProps) => { /> ); }; + +export const Card = (props: SectionProps) => { + return ( +
+ ); +}; diff --git a/packages/emails/src/templates/components/utils.ts b/packages/emails/src/templates/components/utils.ts index 0198f1275..0ceb1bbfd 100644 --- a/packages/emails/src/templates/components/utils.ts +++ b/packages/emails/src/templates/components/utils.ts @@ -1,3 +1,7 @@ +import { absoluteUrl } from "@rallly/utils"; + export const removeProtocalFromUrl = (url: string) => { return url.replace(/(^\w+:|^)\/\//, ""); }; + +export const getDomain = () => removeProtocalFromUrl(absoluteUrl()); diff --git a/packages/emails/src/templates/login.tsx b/packages/emails/src/templates/login.tsx index 09a855fbf..abfe4a24f 100644 --- a/packages/emails/src/templates/login.tsx +++ b/packages/emails/src/templates/login.tsx @@ -1,20 +1,27 @@ import { absoluteUrl } from "@rallly/utils"; +import { Hr } from "@react-email/components"; import { EmailLayout } from "./components/email-layout"; -import { Heading, Link, Text } from "./components/styled-components"; -import { removeProtocalFromUrl } from "./components/utils"; +import { + Button, + Heading, + Link, + Section, + Text, +} from "./components/styled-components"; +import { getDomain, removeProtocalFromUrl } from "./components/utils"; interface LoginEmailProps { name: string; code: string; - // magicLink: string; + magicLink: string; } export const LoginEmail = ({ name = "Guest", code = "123456", -}: // magicLink = "https://rallly.co", -LoginEmailProps) => { + magicLink = "https://rallly.co", +}: LoginEmailProps) => { return ( { recipientName={name} preview={`Your 6-digit code: ${code}`} > - Your 6-digit code is: - - {code} - - - Use this code to complete the verification process on{" "} - {removeProtocalFromUrl(absoluteUrl())} - - - - This code is valid for 15 minutes - - - {/* Magic link - - Alternatively, you can login by using this{" "} - magic link ✨ - */} + Use this link to log in on this device. + + This link is valid for 15 minutes +
+ + Alternatively, you can enter this 6-digit verification code directly. + + + {code} + +
); }; diff --git a/packages/emails/src/templates/new-participant-confirmation.tsx b/packages/emails/src/templates/new-participant-confirmation.tsx index 0d4cc22a3..576d78420 100644 --- a/packages/emails/src/templates/new-participant-confirmation.tsx +++ b/packages/emails/src/templates/new-participant-confirmation.tsx @@ -1,5 +1,13 @@ 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 { name: string; @@ -8,31 +16,32 @@ interface NewParticipantConfirmationEmailProps { } export const NewParticipantConfirmationEmail = ({ title = "Untitled Poll", - name = "Guest", + name = "John", editSubmissionUrl = "https://rallly.co", }: NewParticipantConfirmationEmailProps) => { return ( You are receiving this email because a response was submitting + <> + You are receiving this email because a response was submitted on{" "} + . If this wasn't you, please ignore this email. + } recipientName={name} preview="To edit your response use the link below" > - Thank you for submitting your availability for {title}. + Thank you for responding to {title}. + + + While the poll is still open you can change your response using the link + below. - To review your response, use the link below:
- - - Keep this link safe and do not share it with others. - -
); }; diff --git a/packages/emails/src/templates/new-participant.tsx b/packages/emails/src/templates/new-participant.tsx index 7fb734521..06d2a5e48 100644 --- a/packages/emails/src/templates/new-participant.tsx +++ b/packages/emails/src/templates/new-participant.tsx @@ -8,7 +8,7 @@ export interface NewParticipantEmailProps extends NotificationBaseProps { } export const NewParticipantEmail = ({ - name = "Guest", + name = "John", title = "Untitled Poll", participantName = "Someone", pollUrl = "https://rallly.co", diff --git a/packages/emails/src/templates/new-poll.tsx b/packages/emails/src/templates/new-poll.tsx index 2939d31dd..d68929761 100644 --- a/packages/emails/src/templates/new-poll.tsx +++ b/packages/emails/src/templates/new-poll.tsx @@ -1,8 +1,16 @@ -import { absoluteUrl } from "@rallly/utils"; +import { absoluteUrl, preventWidows } from "@rallly/utils"; import { EmailLayout } from "./components/email-layout"; -import { Heading, Link, Section, Text } from "./components/styled-components"; -import { removeProtocalFromUrl } from "./components/utils"; +import { + Button, + Card, + Heading, + Link, + Section, + SubHeadingText, + Text, +} from "./components/styled-components"; +import { getDomain } from "./components/utils"; export interface NewPollEmailProps { title: string; @@ -22,7 +30,7 @@ const ShareLink = ({ participantLink: string; }>) => { return ( - {children} - - ); -}; - -const LinkContainer = (props: { link: string }) => { - return ( -
- - {props.link} - -
+ ); }; export const NewPollEmail = ({ title = "Untitled Poll", - name = "Guest", + name = "John", adminLink = "https://rallly.co/admin/abcdefg123", participantLink = "https://rallly.co/p/wxyz9876", }: NewPollEmailProps) => { @@ -55,39 +53,51 @@ export const NewPollEmail = ({ footNote={ <> You are receiving this email because a new poll was created with this - email address on{" "} - - {removeProtocalFromUrl(absoluteUrl())} - - . If this wasn't you, please ignore this email. + email address on {getDomain()}. If + this wasn't you, please ignore this email. } recipientName={name} preview="Share your participant link to start collecting responses." > - Your new poll is ready! Now lets find a date for{" "} - {title}. - - - Copy this link and share it with your participants to start collecting - responses. - - - - - Share via email → - - - Your secret link - - Use this link to access the admin page where you can view and edit your - poll. - - - - Go to admin page → + Your poll is live! Here are two links you will need to manage your poll. + + Admin link + + Use this link to view results and make changes to your poll. + + + + {adminLink} + + + + + + + + Participant link + + Copy this link and share it with your participants to start collecting + responses. + + + + {participantLink} + + + + + Share via email + + + ); }; diff --git a/packages/emails/src/templates/register.tsx b/packages/emails/src/templates/register.tsx index 4edf63411..cd2f6f1c9 100644 --- a/packages/emails/src/templates/register.tsx +++ b/packages/emails/src/templates/register.tsx @@ -1,9 +1,8 @@ import { absoluteUrl } from "@rallly/utils"; -import { Heading } from "@react-email/heading"; import { EmailLayout } from "./components/email-layout"; -import { Link, Text } from "./components/styled-components"; -import { removeProtocalFromUrl } from "./components/utils"; +import { Domain, Heading, Link, Text } from "./components/styled-components"; +import { getDomain } from "./components/utils"; interface RegisterEmailProps { name: string; @@ -11,7 +10,7 @@ interface RegisterEmailProps { } export const RegisterEmail = ({ - name = "Guest", + name = "John", code = "123456", }: RegisterEmailProps) => { return ( @@ -21,25 +20,22 @@ export const RegisterEmail = ({ You're receiving this email because a request was made to register an account on{" "} - {removeProtocalFromUrl(absoluteUrl())} + {getDomain()} - . + . If this wasn't you, please ignore this email. } recipientName={name} preview={`Your 6-digit code is: ${code}`} > - Your 6-digit code is: - + + Use this code to complete the verification process on + + Your 6-digit code is: + {code} - - Use this code to complete the verification process on{" "} - {removeProtocalFromUrl(absoluteUrl())} - - - This code is valid for 15 minutes - + This code is valid for 15 minutes ); }; diff --git a/packages/emails/src/templates/turn-on-notifications.tsx b/packages/emails/src/templates/turn-on-notifications.tsx index 4a2d1f419..551467f98 100644 --- a/packages/emails/src/templates/turn-on-notifications.tsx +++ b/packages/emails/src/templates/turn-on-notifications.tsx @@ -1,6 +1,8 @@ import { EmailLayout } from "./components/email-layout"; import { Button, + Card, + Heading, Link, Section, SmallText, @@ -16,14 +18,14 @@ type EnableNotificationsEmailProps = { export const EnableNotificationsEmail = ({ title = "Untitled Poll", - name = "Guest", + name = "John", verificationLink = "https://rallly.co", adminLink = "https://rallly.co", }: EnableNotificationsEmailProps) => { return ( You are receiving this email because a request was made to enable @@ -32,18 +34,19 @@ export const EnableNotificationsEmail = ({ } > - Before we can send you notifications we need to verify your email. + Would you like to get notified when participants respond to{" "} + {title}? - - Click the button below to complete the email verification and enable - notifications for {title}. - -
- -
- The link will expire in 15 minutes. + + Enable notifications + You will get an email when someone responds to the poll. +
+ +
+ The link will expire in 15 minutes. +
); }; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index b58c8fd52..e7fffef94 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -1 +1,2 @@ export * from "./src/absolute-url"; +export * from "./src/prevent-widows"; diff --git a/packages/utils/src/prevent-widows.ts b/packages/utils/src/prevent-widows.ts new file mode 100644 index 000000000..e765c8698 --- /dev/null +++ b/packages/utils/src/prevent-widows.ts @@ -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"); +}