From aaa12234089091eeff531c28b17402534fe7932d Mon Sep 17 00:00:00 2001
From: Bruno Quaresma
Date: Fri, 18 Oct 2024 09:50:22 -0300
Subject: [PATCH] feat(site): add forgot password link (#15108)
Demo:
https://github.com/user-attachments/assets/139eb8c0-5bd6-4bbd-8064-a4acc526afda
---
...date_forgot_password_notification.down.sql | 0
...update_forgot_password_notification.up.sql | 10 +
...teUserRequestedOneTimePasscode.html.golden | 29 +--
...teUserRequestedOneTimePasscode.json.golden | 15 +-
site/src/api/api.ts | 12 ++
site/src/api/queries/users.ts | 14 ++
site/src/components/CustomLogo/CustomLogo.tsx | 33 +++
site/src/pages/LoginPage/LoginPageView.tsx | 28 +--
.../pages/LoginPage/PasswordSignInForm.tsx | 13 ++
.../ChangePasswordPage.stories.tsx | 73 +++++++
.../ResetPasswordPage/ChangePasswordPage.tsx | 174 ++++++++++++++++
.../RequestOTPPage.stories.tsx | 43 ++++
.../ResetPasswordPage/RequestOTPPage.tsx | 193 ++++++++++++++++++
site/src/router.tsx | 10 +
14 files changed, 603 insertions(+), 44 deletions(-)
create mode 100644 coderd/database/migrations/000266_update_forgot_password_notification.down.sql
create mode 100644 coderd/database/migrations/000266_update_forgot_password_notification.up.sql
create mode 100644 site/src/components/CustomLogo/CustomLogo.tsx
create mode 100644 site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx
create mode 100644 site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx
create mode 100644 site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx
create mode 100644 site/src/pages/ResetPasswordPage/RequestOTPPage.tsx
diff --git a/coderd/database/migrations/000266_update_forgot_password_notification.down.sql b/coderd/database/migrations/000266_update_forgot_password_notification.down.sql
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/coderd/database/migrations/000266_update_forgot_password_notification.up.sql b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql
new file mode 100644
index 0000000000..d7d6e5f176
--- /dev/null
+++ b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql
@@ -0,0 +1,10 @@
+UPDATE notification_templates
+SET
+ title_template = E'Reset your password for Coder',
+ body_template = E'Hi {{.UserName}},\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message.',
+ actions = '[{
+ "label": "Reset password",
+ "url": "{{ base_url }}/reset-password/change?otp={{.Labels.one_time_passcode}}&email={{ .UserEmail }}"
+ }]'::jsonb
+WHERE
+ id = '62f86a30-2330-4b61-a26d-311ff3b608cf'
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden
index 2b61765813..fc74699e70 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden
@@ -1,6 +1,6 @@
From: system@coder.com
To: bobby@coder.com
-Subject: Your One-Time Passcode for Coder.
+Subject: Reset your password for Coder
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
@@ -12,13 +12,13 @@ Content-Type: text/plain; charset=UTF-8
Hi Bobby,
-A request to reset the password for your Coder account has been made. Your =
-one-time passcode is:
+Use the link below to reset your password.
-fad9020b-6562-4cdb-87f1-0486f1bea415
+If you did not make this request, you can ignore this message.
-If you did not request to reset your password, you can ignore this message.
+Reset password: http://test.com/reset-password/change?otp=3Dfad9020b-6562-4=
+cdb-87f1-0486f1bea415&email=3Dbobby@coder.com
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
- Your One-Time Passcode for Coder.
+ Reset your password for Coder
- Your One-Time Passcode for Coder.
+ Reset your password for Coder
Hi Bobby,
-
A request to reset the password for your Coder account has been made. Yo=
-ur one-time passcode is:
+
Use the link below to reset your password.
-
fad9020b-6562-4cdb-87f1-0486f1bea415
-
-
If you did not request to reset your password, you can ignore this messa=
-ge.
+
If you did not make this request, you can ignore this message.
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden
index 2c03fc7c71..b3610b6661 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden
@@ -9,14 +9,19 @@
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
- "actions": [],
+ "actions": [
+ {
+ "label": "Reset password",
+ "url": "http://test.com/reset-password/change?otp=00000000-0000-0000-0000-000000000000\u0026email=bobby@coder.com"
+ }
+ ],
"labels": {
"one_time_passcode": "00000000-0000-0000-0000-000000000000"
},
"data": null
},
- "title": "Your One-Time Passcode for Coder.",
- "title_markdown": "Your One-Time Passcode for Coder.",
- "body": "Hi Bobby,\n\nA request to reset the password for your Coder account has been made. Your one-time passcode is:\n\n00000000-0000-0000-0000-000000000000\n\nIf you did not request to reset your password, you can ignore this message.",
- "body_markdown": "Hi Bobby,\n\nA request to reset the password for your Coder account has been made. Your one-time passcode is:\n\n**00000000-0000-0000-0000-000000000000**\n\nIf you did not request to reset your password, you can ignore this message."
+ "title": "Reset your password for Coder",
+ "title_markdown": "Reset your password for Coder",
+ "body": "Hi Bobby,\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message.",
+ "body_markdown": "Hi Bobby,\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message."
}
\ No newline at end of file
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 103a3c50e7..7d87d9c8c2 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -2167,6 +2167,18 @@ class ApiMethods {
);
return res.data;
};
+
+ requestOneTimePassword = async (
+ req: TypesGen.RequestOneTimePasscodeRequest,
+ ) => {
+ await this.axios.post
("/api/v2/users/otp/request", req);
+ };
+
+ changePasswordWithOTP = async (
+ req: TypesGen.ChangePasswordWithOneTimePasscodeRequest,
+ ) => {
+ await this.axios.post("/api/v2/users/otp/change-password", req);
+ };
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts
index 427054b3fe..833d88e6ba 100644
--- a/site/src/api/queries/users.ts
+++ b/site/src/api/queries/users.ts
@@ -3,6 +3,7 @@ import type {
AuthorizationRequest,
GenerateAPIKeyResponse,
GetUsersResponse,
+ RequestOneTimePasscodeRequest,
UpdateUserAppearanceSettingsRequest,
UpdateUserPasswordRequest,
UpdateUserProfileRequest,
@@ -253,3 +254,16 @@ export const updateAppearanceSettings = (
},
};
};
+
+export const requestOneTimePassword = () => {
+ return {
+ mutationFn: (req: RequestOneTimePasscodeRequest) =>
+ API.requestOneTimePassword(req),
+ };
+};
+
+export const changePasswordWithOTP = () => {
+ return {
+ mutationFn: API.changePasswordWithOTP,
+ };
+};
diff --git a/site/src/components/CustomLogo/CustomLogo.tsx b/site/src/components/CustomLogo/CustomLogo.tsx
new file mode 100644
index 0000000000..e207e8fac2
--- /dev/null
+++ b/site/src/components/CustomLogo/CustomLogo.tsx
@@ -0,0 +1,33 @@
+import type { Interpolation, Theme } from "@emotion/react";
+import { CoderIcon } from "components/Icons/CoderIcon";
+import type { FC } from "react";
+import { getApplicationName, getLogoURL } from "utils/appearance";
+
+/**
+ * Enterprise customers can set a custom logo for their Coder application. Use
+ * the custom logo wherever the Coder logo is used, if a custom one is provided.
+ */
+export const CustomLogo: FC<{ css?: Interpolation }> = (props) => {
+ const applicationName = getApplicationName();
+ const logoURL = getLogoURL();
+
+ return logoURL ? (
+
{
+ e.currentTarget.style.display = "none";
+ }}
+ onLoad={(e) => {
+ e.currentTarget.style.display = "inline";
+ }}
+ css={{ maxWidth: 200 }}
+ className="application-logo"
+ />
+ ) : (
+
+ );
+};
diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx
index 8b9a5ec472..9404722431 100644
--- a/site/src/pages/LoginPage/LoginPageView.tsx
+++ b/site/src/pages/LoginPage/LoginPageView.tsx
@@ -1,11 +1,10 @@
import type { Interpolation, Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
-import { CoderIcon } from "components/Icons/CoderIcon";
+import { CustomLogo } from "components/CustomLogo/CustomLogo";
import { Loader } from "components/Loader/Loader";
import { type FC, useState } from "react";
import { useLocation } from "react-router-dom";
-import { getApplicationName, getLogoURL } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { SignInForm } from "./SignInForm";
import { TermsOfServiceLink } from "./TermsOfServiceLink";
@@ -32,29 +31,6 @@ export const LoginPageView: FC = ({
// This allows messages to be displayed at the top of the sign in form.
// Helpful for any redirects that want to inform the user of something.
const message = new URLSearchParams(location.search).get("message");
- const applicationName = getApplicationName();
- const logoURL = getLogoURL();
- const applicationLogo = logoURL ? (
-
{
- e.currentTarget.style.display = "none";
- }}
- onLoad={(e) => {
- e.currentTarget.style.display = "inline";
- }}
- css={{
- maxWidth: "200px",
- }}
- className="application-logo"
- />
- ) : (
-
- );
-
const [tosAccepted, setTosAccepted] = useState(false);
const tosAcceptanceRequired =
authMethods?.terms_of_service_url && !tosAccepted;
@@ -62,7 +38,7 @@ export const LoginPageView: FC = ({
return (
- {applicationLogo}
+
{isLoading ? (
) : tosAcceptanceRequired ? (
diff --git a/site/src/pages/LoginPage/PasswordSignInForm.tsx b/site/src/pages/LoginPage/PasswordSignInForm.tsx
index d1e7ab9194..e2ca4dc5bc 100644
--- a/site/src/pages/LoginPage/PasswordSignInForm.tsx
+++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx
@@ -1,8 +1,10 @@
import LoadingButton from "@mui/lab/LoadingButton";
+import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik";
import type { FC } from "react";
+import { Link as RouterLink } from "react-router-dom";
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
import * as Yup from "yup";
import { Language } from "./SignInForm";
@@ -65,6 +67,17 @@ export const PasswordSignInForm: FC
= ({
>
{Language.passwordSignIn}
+
+ Forgot password?
+
);
diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx
new file mode 100644
index 0000000000..d59ead3a59
--- /dev/null
+++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { expect, spyOn, userEvent, within } from "@storybook/test";
+import { API } from "api/api";
+import { mockApiError } from "testHelpers/entities";
+import { withGlobalSnackbar } from "testHelpers/storybook";
+import ChangePasswordPage from "./ChangePasswordPage";
+
+const meta: Meta = {
+ title: "pages/ResetPasswordPage/ChangePasswordPage",
+ component: ChangePasswordPage,
+ args: { redirect: false },
+ decorators: [withGlobalSnackbar],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Success: Story = {
+ play: async ({ canvasElement }) => {
+ spyOn(API, "changePasswordWithOTP").mockResolvedValueOnce();
+ const canvas = within(canvasElement);
+ const user = userEvent.setup();
+ const newPasswordInput = await canvas.findByLabelText("Password *");
+ await user.type(newPasswordInput, "password");
+ const confirmPasswordInput =
+ await canvas.findByLabelText("Confirm password *");
+ await user.type(confirmPasswordInput, "password");
+ await user.click(canvas.getByRole("button", { name: /reset password/i }));
+ await canvas.findByText("Password reset successfully");
+ },
+};
+
+export const WrongConfirmationPassword: Story = {
+ play: async ({ canvasElement }) => {
+ spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce(
+ mockApiError({
+ message: "New password should be different from the old password",
+ }),
+ );
+ const canvas = within(canvasElement);
+ const user = userEvent.setup();
+ const newPasswordInput = await canvas.findByLabelText("Password *");
+ await user.type(newPasswordInput, "password");
+ const confirmPasswordInput =
+ await canvas.findByLabelText("Confirm password *");
+ await user.type(confirmPasswordInput, "different-password");
+ await user.click(canvas.getByRole("button", { name: /reset password/i }));
+ await canvas.findByText("Passwords must match");
+ },
+};
+
+export const ServerError: Story = {
+ play: async ({ canvasElement }) => {
+ const serverError =
+ "New password should be different from the old password";
+ spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce(
+ mockApiError({
+ message: serverError,
+ }),
+ );
+ const canvas = within(canvasElement);
+ const user = userEvent.setup();
+ const newPasswordInput = await canvas.findByLabelText("Password *");
+ await user.type(newPasswordInput, "password");
+ const confirmPasswordInput =
+ await canvas.findByLabelText("Confirm password *");
+ await user.type(confirmPasswordInput, "password");
+ await user.click(canvas.getByRole("button", { name: /reset password/i }));
+ await canvas.findByText(serverError);
+ },
+};
diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx
new file mode 100644
index 0000000000..077bc39da8
--- /dev/null
+++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx
@@ -0,0 +1,174 @@
+import type { Interpolation, Theme } from "@emotion/react";
+import LoadingButton from "@mui/lab/LoadingButton";
+import Button from "@mui/material/Button";
+import TextField from "@mui/material/TextField";
+import { changePasswordWithOTP } from "api/queries/users";
+import { ErrorAlert } from "components/Alert/ErrorAlert";
+import { CustomLogo } from "components/CustomLogo/CustomLogo";
+import { displaySuccess } from "components/GlobalSnackbar/utils";
+import { Stack } from "components/Stack/Stack";
+import { useFormik } from "formik";
+import type { FC } from "react";
+import { Helmet } from "react-helmet-async";
+import { useMutation } from "react-query";
+import {
+ Link as RouterLink,
+ useNavigate,
+ useSearchParams,
+} from "react-router-dom";
+import { getApplicationName } from "utils/appearance";
+import { getFormHelpers } from "utils/formUtils";
+import * as yup from "yup";
+
+const validationSchema = yup.object({
+ password: yup.string().required("Password is required"),
+ confirmPassword: yup
+ .string()
+ .required("Confirm password is required")
+ .test("passwords-match", "Passwords must match", function (value) {
+ return this.parent.password === value;
+ }),
+});
+
+type ChangePasswordChangeProps = {
+ // This is used to prevent redirection when testing the page in Storybook and
+ // capturing Chromatic snapshots.
+ redirect?: boolean;
+};
+
+const ChangePasswordPage: FC = ({ redirect }) => {
+ const navigate = useNavigate();
+ const applicationName = getApplicationName();
+ const changePasswordMutation = useMutation(changePasswordWithOTP());
+ const [searchParams] = useSearchParams();
+
+ const form = useFormik({
+ initialValues: {
+ password: "",
+ confirmPassword: "",
+ },
+ validateOnBlur: false,
+ validationSchema,
+ onSubmit: async (values) => {
+ const email = searchParams.get("email") ?? "";
+ const otp = searchParams.get("otp") ?? "";
+
+ await changePasswordMutation.mutateAsync({
+ email,
+ one_time_passcode: otp,
+ password: values.password,
+ });
+ displaySuccess("Password reset successfully");
+ if (redirect) {
+ navigate("/login");
+ }
+ },
+ });
+ const getFieldHelpers = getFormHelpers(form);
+
+ return (
+ <>
+
+ Reset Password - {applicationName}
+
+
+
+
+
+
+ Choose a new password
+
+ {changePasswordMutation.error ? (
+
+ ) : null}
+
+
+
+ >
+ );
+};
+
+const styles = {
+ logo: {
+ marginBottom: 40,
+ },
+ root: {
+ padding: 24,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexDirection: "column",
+ minHeight: "100%",
+ textAlign: "center",
+ },
+ container: {
+ width: "100%",
+ maxWidth: 320,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ },
+ icon: {
+ fontSize: 64,
+ },
+ footer: (theme) => ({
+ fontSize: 12,
+ color: theme.palette.text.secondary,
+ marginTop: 24,
+ }),
+} satisfies Record>;
+
+export default ChangePasswordPage;
diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx
new file mode 100644
index 0000000000..5f75f607ab
--- /dev/null
+++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { spyOn, userEvent, within } from "@storybook/test";
+import { API } from "api/api";
+import { mockApiError } from "testHelpers/entities";
+import { withGlobalSnackbar } from "testHelpers/storybook";
+import RequestOTPPage from "./RequestOTPPage";
+
+const meta: Meta = {
+ title: "pages/ResetPasswordPage/RequestOTPPage",
+ component: RequestOTPPage,
+ decorators: [withGlobalSnackbar],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Success: Story = {
+ play: async ({ canvasElement }) => {
+ spyOn(API, "requestOneTimePassword").mockResolvedValueOnce();
+ const canvas = within(canvasElement);
+ const user = userEvent.setup();
+ const emailInput = await canvas.findByLabelText(/email/i);
+ await user.type(emailInput, "admin@coder.com");
+ await user.click(canvas.getByRole("button", { name: /reset password/i }));
+ },
+};
+
+export const ServerError: Story = {
+ play: async ({ canvasElement }) => {
+ spyOn(API, "requestOneTimePassword").mockRejectedValueOnce(
+ mockApiError({
+ message: "Error requesting password change",
+ }),
+ );
+ const canvas = within(canvasElement);
+ const user = userEvent.setup();
+ const emailInput = await canvas.findByLabelText(/email/i);
+ await user.type(emailInput, "admin@coder.com");
+ await user.click(canvas.getByRole("button", { name: /reset password/i }));
+ },
+};
diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx
new file mode 100644
index 0000000000..0a097971b6
--- /dev/null
+++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx
@@ -0,0 +1,193 @@
+import { type Interpolation, type Theme, useTheme } from "@emotion/react";
+import LoadingButton from "@mui/lab/LoadingButton";
+import Button from "@mui/material/Button";
+import TextField from "@mui/material/TextField";
+import { getErrorMessage } from "api/errors";
+import { requestOneTimePassword } from "api/queries/users";
+import { ErrorAlert } from "components/Alert/ErrorAlert";
+import { CustomLogo } from "components/CustomLogo/CustomLogo";
+import { displayError } from "components/GlobalSnackbar/utils";
+import { Stack } from "components/Stack/Stack";
+import type { FC } from "react";
+import { Helmet } from "react-helmet-async";
+import { useMutation } from "react-query";
+import { Link as RouterLink } from "react-router-dom";
+import { getApplicationName } from "utils/appearance";
+
+const RequestOTPPage: FC = () => {
+ const applicationName = getApplicationName();
+ const requestOTPMutation = useMutation(requestOneTimePassword());
+
+ return (
+ <>
+
+ Reset Password - {applicationName}
+
+
+
+
+ {requestOTPMutation.isSuccess ? (
+
+ ) : (
+ {
+ requestOTPMutation.mutate({ email });
+ }}
+ />
+ )}
+
+ >
+ );
+};
+
+type RequestOTPProps = {
+ error: unknown;
+ onRequest: (email: string) => void;
+ isRequesting: boolean;
+};
+
+const RequestOTP: FC = ({
+ error,
+ onRequest,
+ isRequesting,
+}) => {
+ return (
+
+
+
+ Enter your email to reset the password
+
+ {error ? : null}
+
+
+
+ );
+};
+
+const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+ If the account{" "}
+
+ {email}
+ {" "}
+ exists, you will get an email with instructions on resetting your
+ password.
+
+
+
+ Contact your deployment administrator if you encounter issues.
+
+
+
+
+
+ );
+};
+
+const styles = {
+ logo: {
+ marginBottom: 40,
+ },
+ root: {
+ padding: 24,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexDirection: "column",
+ minHeight: "100%",
+ textAlign: "center",
+ },
+ container: {
+ width: "100%",
+ maxWidth: 320,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ },
+ icon: {
+ fontSize: 64,
+ },
+ footer: (theme) => ({
+ fontSize: 12,
+ color: theme.palette.text.secondary,
+ marginTop: 24,
+ }),
+} satisfies Record>;
+
+export default RequestOTPPage;
diff --git a/site/src/router.tsx b/site/src/router.tsx
index 526be40a2b..2531c823b9 100644
--- a/site/src/router.tsx
+++ b/site/src/router.tsx
@@ -287,6 +287,12 @@ const DeploymentNotificationsPage = lazy(
"./pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage"
),
);
+const RequestOTPPage = lazy(
+ () => import("./pages/ResetPasswordPage/RequestOTPPage"),
+);
+const ChangePasswordPage = lazy(
+ () => import("./pages/ResetPasswordPage/ChangePasswordPage"),
+);
const RoutesWithSuspense = () => {
return (
@@ -348,6 +354,10 @@ export const router = createBrowserRouter(
}>
} />
} />
+
+ } />
+ } />
+
{/* Dashboard routes */}
}>