diff --git a/apps/web/app/future/auth/forgot-password/[id]/page.tsx b/apps/web/app/future/auth/forgot-password/[id]/page.tsx new file mode 100644 index 0000000000..126faa3a7c --- /dev/null +++ b/apps/web/app/future/auth/forgot-password/[id]/page.tsx @@ -0,0 +1,19 @@ +import SetNewUserPassword from "@pages/auth/forgot-password/[id]"; +import { withAppDirSsr } from "app/WithAppDirSsr"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("reset_password"), + (t) => t("change_your_password") + ); +}; + +export default WithLayout({ + getLayout: null, + Page: SetNewUserPassword, + getData: withAppDirSsr(getServerSideProps), +})<"P">; diff --git a/apps/web/app/future/auth/forgot-password/page.tsx b/apps/web/app/future/auth/forgot-password/page.tsx new file mode 100644 index 0000000000..5b3bc27871 --- /dev/null +++ b/apps/web/app/future/auth/forgot-password/page.tsx @@ -0,0 +1,19 @@ +import ForgotPassword from "@pages/auth/forgot-password"; +import { withAppDirSsr } from "app/WithAppDirSsr"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { getServerSideProps } from "@server/lib/forgot-password/getServerSideProps"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("reset_password"), + (t) => t("change_your_password") + ); +}; + +export default WithLayout({ + getLayout: null, + Page: ForgotPassword, + getData: withAppDirSsr(getServerSideProps), +})<"P">; diff --git a/apps/web/pages/auth/forgot-password/[id].tsx b/apps/web/pages/auth/forgot-password/[id].tsx index 4bbb7e6a8c..cbc7e4ad29 100644 --- a/apps/web/pages/auth/forgot-password/[id].tsx +++ b/apps/web/pages/auth/forgot-password/[id].tsx @@ -1,22 +1,21 @@ -import type { GetServerSidePropsContext } from "next"; -import { getCsrfToken } from "next-auth/react"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +"use client"; + import Link from "next/link"; import type { CSSProperties } from "react"; import { useForm } from "react-hook-form"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import prisma from "@calcom/prisma"; import { Button, PasswordField, Form } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; import AuthContainer from "@components/ui/AuthContainer"; +import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps"; + type Props = { requestId: string; isRequestExpired: boolean; - csrfToken: string; + csrfToken: string | undefined; }; export default function Page({ requestId, isRequestExpired, csrfToken }: Props) { @@ -143,33 +142,4 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props) } Page.PageWrapper = PageWrapper; -export async function getServerSideProps(context: GetServerSidePropsContext) { - const id = context.params?.id as string; - - let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({ - where: { - id, - expires: { - gt: new Date(), - }, - }, - select: { - email: true, - }, - }); - try { - resetPasswordRequest && - (await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } })); - } catch (e) { - resetPasswordRequest = null; - } - const locale = await getLocale(context.req); - return { - props: { - isRequestExpired: !resetPasswordRequest, - requestId: id, - csrfToken: await getCsrfToken({ req: context.req }), - ...(await serverSideTranslations(locale, ["common"])), - }, - }; -} +export { getServerSideProps }; diff --git a/apps/web/pages/auth/forgot-password/index.tsx b/apps/web/pages/auth/forgot-password/index.tsx index 6e7df24840..8c473d404b 100644 --- a/apps/web/pages/auth/forgot-password/index.tsx +++ b/apps/web/pages/auth/forgot-password/index.tsx @@ -1,21 +1,23 @@ +"use client"; + // eslint-disable-next-line no-restricted-imports import { debounce } from "lodash"; -import type { GetServerSidePropsContext } from "next"; -import { getCsrfToken } from "next-auth/react"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import Link from "next/link"; import type { CSSProperties, SyntheticEvent } from "react"; import React from "react"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, EmailField } from "@calcom/ui"; +import { type inferSSRProps } from "@lib/types/inferSSRProps"; + import PageWrapper from "@components/PageWrapper"; import AuthContainer from "@components/ui/AuthContainer"; -export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { +import { getServerSideProps } from "@server/lib/forgot-password/getServerSideProps"; + +export default function ForgotPassword(props: inferSSRProps) { + const csrfToken = "csrfToken" in props ? (props.csrfToken as string) : undefined; const { t } = useLocale(); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState<{ message: string } | null>(null); @@ -145,23 +147,4 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { ForgotPassword.PageWrapper = PageWrapper; -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const { req, res } = context; - - const session = await getServerSession({ req }); - - // @TODO res will not be available in future pages (app dir) - if (session) { - res.writeHead(302, { Location: "/" }); - res.end(); - return { props: {} }; - } - const locale = await getLocale(context.req); - - return { - props: { - csrfToken: await getCsrfToken(context), - ...(await serverSideTranslations(locale, ["common"])), - }, - }; -}; +export { getServerSideProps }; diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index ad4d30aa3a..79bf74ccfa 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -5,89 +5,92 @@ import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; import prisma from "@calcom/prisma"; import { test } from "../lib/fixtures"; +import { testBothFutureAndLegacyRoutes } from "../lib/future-legacy-routes"; test.afterEach(({ users }) => users.deleteAll()); -test("Can reset forgotten password", async ({ page, users }) => { - const user = await users.create(); +testBothFutureAndLegacyRoutes.describe("Forgot password", async () => { + test("Can reset forgotten password", async ({ page, users }) => { + const user = await users.create(); - // Got to reset password flow - await page.goto("/auth/forgot-password"); + // Got to reset password flow + await page.goto("/auth/forgot-password"); - await page.fill('input[name="email"]', `${user.username}@example.com`); - await page.press('input[name="email"]', "Enter"); + await page.fill('input[name="email"]', `${user.username}@example.com`); + await page.press('input[name="email"]', "Enter"); - // wait for confirm page. - await page.waitForSelector("text=Reset link sent"); + // wait for confirm page. + await page.waitForSelector("text=Reset link sent"); - // As a workaround, we query the db for the last created password request - // there should be one, otherwise we throw - const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({ - where: { - email: user.email, - }, - select: { - id: true, - }, - orderBy: { - createdAt: "desc", - }, + // As a workaround, we query the db for the last created password request + // there should be one, otherwise we throw + const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Test when a user changes his email after starting the password reset flow + await prisma.user.update({ + where: { + email: user.email, + }, + data: { + email: `${user.username}-2@example.com`, + }, + }); + + await page.goto(`/auth/forgot-password/${id}`); + + await page.waitForSelector("text=That request is expired."); + + // Change the email back to continue testing. + await prisma.user.update({ + where: { + email: `${user.username}-2@example.com`, + }, + data: { + email: user.email, + }, + }); + + await page.goto(`/auth/forgot-password/${id}`); + + const newPassword = `${user.username}-123CAL-${uuid().toString()}`; // To match the password policy + + // Wait for page to fully load + await page.waitForSelector("text=Reset Password"); + + await page.fill('input[name="new_password"]', newPassword); + await page.click('button[type="submit"]'); + + await page.waitForSelector("text=Password updated"); + + await expect(page.locator(`text=Password updated`)).toBeVisible(); + // now we check our DB to confirm the password was indeed updated. + // we're not logging in to the UI to speed up test performance. + const updatedUser = await prisma.user.findUniqueOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + password: true, + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy(); + + // finally, make sure the same URL cannot be used to reset the password again, as it should be expired. + await page.goto(`/auth/forgot-password/${id}`); + + await expect(page.locator(`text=Whoops`)).toBeVisible(); }); - - // Test when a user changes his email after starting the password reset flow - await prisma.user.update({ - where: { - email: user.email, - }, - data: { - email: `${user.username}-2@example.com`, - }, - }); - - await page.goto(`/auth/forgot-password/${id}`); - - await page.waitForSelector("text=That request is expired."); - - // Change the email back to continue testing. - await prisma.user.update({ - where: { - email: `${user.username}-2@example.com`, - }, - data: { - email: user.email, - }, - }); - - await page.goto(`/auth/forgot-password/${id}`); - - const newPassword = `${user.username}-123CAL-${uuid().toString()}`; // To match the password policy - - // Wait for page to fully load - await page.waitForSelector("text=Reset Password"); - - await page.fill('input[name="new_password"]', newPassword); - await page.click('button[type="submit"]'); - - await page.waitForSelector("text=Password updated"); - - await expect(page.locator(`text=Password updated`)).toBeVisible(); - // now we check our DB to confirm the password was indeed updated. - // we're not logging in to the UI to speed up test performance. - const updatedUser = await prisma.user.findUniqueOrThrow({ - where: { - email: user.email, - }, - select: { - id: true, - password: true, - }, - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy(); - - // finally, make sure the same URL cannot be used to reset the password again, as it should be expired. - await page.goto(`/auth/forgot-password/${id}`); - - await expect(page.locator(`text=Whoops`)).toBeVisible(); }); diff --git a/apps/web/server/lib/forgot-password/[id]/getServerSideProps.tsx b/apps/web/server/lib/forgot-password/[id]/getServerSideProps.tsx new file mode 100644 index 0000000000..0ae3cf1906 --- /dev/null +++ b/apps/web/server/lib/forgot-password/[id]/getServerSideProps.tsx @@ -0,0 +1,37 @@ +import type { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +import { getLocale } from "@calcom/features/auth/lib/getLocale"; +import prisma from "@calcom/prisma"; + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const id = context.params?.id as string; + + let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({ + where: { + id, + expires: { + gt: new Date(), + }, + }, + select: { + email: true, + }, + }); + try { + resetPasswordRequest && + (await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } })); + } catch (e) { + resetPasswordRequest = null; + } + const locale = await getLocale(context.req); + return { + props: { + isRequestExpired: !resetPasswordRequest, + requestId: id, + csrfToken: await getCsrfToken({ req: context.req }), + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +} diff --git a/apps/web/server/lib/forgot-password/getServerSideProps.tsx b/apps/web/server/lib/forgot-password/getServerSideProps.tsx new file mode 100644 index 0000000000..5c7faca873 --- /dev/null +++ b/apps/web/server/lib/forgot-password/getServerSideProps.tsx @@ -0,0 +1,27 @@ +import type { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +import { getLocale } from "@calcom/features/auth/lib/getLocale"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { req, res } = context; + + const session = await getServerSession({ req }); + + // @TODO res will not be available in future pages (app dir) + if (session) { + res.writeHead(302, { Location: "/" }); + res.end(); + return { props: {} }; + } + const locale = await getLocale(context.req); + + return { + props: { + csrfToken: await getCsrfToken(context), + ...(await serverSideTranslations(locale, ["common"])), + }, + }; +}