mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): add forgot password link (#15108)
Demo: https://github.com/user-attachments/assets/139eb8c0-5bd6-4bbd-8064-a4acc526afda
This commit is contained in:
@@ -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'
|
||||
+16
-13
@@ -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
|
||||
<meta charset=3D"UTF-8" />
|
||||
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
|
||||
=3D1.0" />
|
||||
<title>Your One-Time Passcode for Coder.</title>
|
||||
<title>Reset your password for Coder</title>
|
||||
</head>
|
||||
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
|
||||
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
|
||||
@@ -45,21 +45,24 @@ er Logo" style=3D"height: 40px;" />
|
||||
</div>
|
||||
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
|
||||
argin: 8px 0 32px; line-height: 1.5;">
|
||||
Your One-Time Passcode for Coder.
|
||||
Reset your password for Coder
|
||||
</h1>
|
||||
<div style=3D"line-height: 1.5;">
|
||||
<p>Hi Bobby,</p>
|
||||
|
||||
<p>A request to reset the password for your Coder account has been made. Yo=
|
||||
ur one-time passcode is:</p>
|
||||
<p>Use the link below to reset your password.</p>
|
||||
|
||||
<p><strong>fad9020b-6562-4cdb-87f1-0486f1bea415</strong></p>
|
||||
|
||||
<p>If you did not request to reset your password, you can ignore this messa=
|
||||
ge.</p>
|
||||
<p>If you did not make this request, you can ignore this message.</p>
|
||||
</div>
|
||||
<div style=3D"text-align: center; margin-top: 32px;">
|
||||
=20
|
||||
<a href=3D"http://test.com/reset-password/change?otp=3Dfad9020b-656=
|
||||
2-4cdb-87f1-0486f1bea415&email=3Dbobby@coder.com" style=3D"display: inline-=
|
||||
block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-=
|
||||
decoration: none; border-radius: 8px; margin: 0 4px;">
|
||||
Reset password
|
||||
</a>
|
||||
=20
|
||||
</div>
|
||||
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
|
||||
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
|
||||
|
||||
+10
-5
@@ -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."
|
||||
}
|
||||
@@ -2167,6 +2167,18 @@ class ApiMethods {
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
requestOneTimePassword = async (
|
||||
req: TypesGen.RequestOneTimePasscodeRequest,
|
||||
) => {
|
||||
await this.axios.post<void>("/api/v2/users/otp/request", req);
|
||||
};
|
||||
|
||||
changePasswordWithOTP = async (
|
||||
req: TypesGen.ChangePasswordWithOneTimePasscodeRequest,
|
||||
) => {
|
||||
await this.axios.post<void>("/api/v2/users/otp/change-password", req);
|
||||
};
|
||||
}
|
||||
|
||||
// This is a hard coded CSRF token/cookie pair for local development. In prod,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<Theme> }> = (props) => {
|
||||
const applicationName = getApplicationName();
|
||||
const logoURL = getLogoURL();
|
||||
|
||||
return logoURL ? (
|
||||
<img
|
||||
{...props}
|
||||
alt={applicationName}
|
||||
src={logoURL}
|
||||
// This prevent browser to display the ugly error icon if the
|
||||
// image path is wrong or user didn't finish typing the url
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = "inline";
|
||||
}}
|
||||
css={{ maxWidth: 200 }}
|
||||
className="application-logo"
|
||||
/>
|
||||
) : (
|
||||
<CoderIcon {...props} css={[{ fontSize: 64, fill: "white" }, props.css]} />
|
||||
);
|
||||
};
|
||||
@@ -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<LoginPageViewProps> = ({
|
||||
// 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 ? (
|
||||
<img
|
||||
alt={applicationName}
|
||||
src={logoURL}
|
||||
// This prevent browser to display the ugly error icon if the
|
||||
// image path is wrong or user didn't finish typing the url
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = "inline";
|
||||
}}
|
||||
css={{
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
className="application-logo"
|
||||
/>
|
||||
) : (
|
||||
<CoderIcon fill="white" opacity={1} css={styles.icon} />
|
||||
);
|
||||
|
||||
const [tosAccepted, setTosAccepted] = useState(false);
|
||||
const tosAcceptanceRequired =
|
||||
authMethods?.terms_of_service_url && !tosAccepted;
|
||||
@@ -62,7 +38,7 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<div css={styles.container}>
|
||||
{applicationLogo}
|
||||
<CustomLogo />
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : tosAcceptanceRequired ? (
|
||||
|
||||
@@ -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<PasswordSignInFormProps> = ({
|
||||
>
|
||||
{Language.passwordSignIn}
|
||||
</LoadingButton>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/reset-password"
|
||||
css={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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<typeof ChangePasswordPage> = {
|
||||
title: "pages/ResetPasswordPage/ChangePasswordPage",
|
||||
component: ChangePasswordPage,
|
||||
args: { redirect: false },
|
||||
decorators: [withGlobalSnackbar],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ChangePasswordPage>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -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<ChangePasswordChangeProps> = ({ 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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Reset Password - {applicationName}</title>
|
||||
</Helmet>
|
||||
|
||||
<div css={styles.root}>
|
||||
<main css={styles.container}>
|
||||
<CustomLogo css={styles.logo} />
|
||||
<h1
|
||||
css={{
|
||||
margin: 0,
|
||||
marginBottom: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: "28px",
|
||||
}}
|
||||
>
|
||||
Choose a new password
|
||||
</h1>
|
||||
{changePasswordMutation.error ? (
|
||||
<ErrorAlert
|
||||
error={changePasswordMutation.error}
|
||||
css={{ marginBottom: 24 }}
|
||||
/>
|
||||
) : null}
|
||||
<form css={{ width: "100%" }} onSubmit={form.handleSubmit}>
|
||||
<fieldset disabled={form.isSubmitting}>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
label="Password"
|
||||
autoFocus
|
||||
fullWidth
|
||||
required
|
||||
type="password"
|
||||
{...getFieldHelpers("password")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
fullWidth
|
||||
required
|
||||
type="password"
|
||||
{...getFieldHelpers("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<LoadingButton
|
||||
loading={form.isSubmitting}
|
||||
type="submit"
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
>
|
||||
Reset password
|
||||
</LoadingButton>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="text"
|
||||
to="/login"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
|
||||
export default ChangePasswordPage;
|
||||
@@ -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<typeof RequestOTPPage> = {
|
||||
title: "pages/ResetPasswordPage/RequestOTPPage",
|
||||
component: RequestOTPPage,
|
||||
decorators: [withGlobalSnackbar],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RequestOTPPage>;
|
||||
|
||||
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 }));
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Reset Password - {applicationName}</title>
|
||||
</Helmet>
|
||||
|
||||
<main css={styles.root}>
|
||||
<CustomLogo css={styles.logo} />
|
||||
{requestOTPMutation.isSuccess ? (
|
||||
<RequestOTPSuccess
|
||||
email={requestOTPMutation.variables?.email ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<RequestOTP
|
||||
error={requestOTPMutation.error}
|
||||
isRequesting={requestOTPMutation.isLoading}
|
||||
onRequest={(email) => {
|
||||
requestOTPMutation.mutate({ email });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type RequestOTPProps = {
|
||||
error: unknown;
|
||||
onRequest: (email: string) => void;
|
||||
isRequesting: boolean;
|
||||
};
|
||||
|
||||
const RequestOTP: FC<RequestOTPProps> = ({
|
||||
error,
|
||||
onRequest,
|
||||
isRequesting,
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.container}>
|
||||
<div>
|
||||
<h1
|
||||
css={{
|
||||
margin: 0,
|
||||
marginBottom: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: "28px",
|
||||
}}
|
||||
>
|
||||
Enter your email to reset the password
|
||||
</h1>
|
||||
{error ? <ErrorAlert error={error} css={{ marginBottom: 24 }} /> : null}
|
||||
<form
|
||||
css={{ width: "100%" }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const email = e.currentTarget.email.value;
|
||||
onRequest(email);
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={isRequesting}>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
name="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
autoFocus
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<LoadingButton
|
||||
loading={isRequesting}
|
||||
type="submit"
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
>
|
||||
Reset password
|
||||
</LoadingButton>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="text"
|
||||
to="/login"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
...styles.container,
|
||||
maxWidth: 380,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p css={{ margin: 0, marginBottom: 56 }}>
|
||||
If the account{" "}
|
||||
<span css={{ fontWeight: 600, color: theme.palette.text.secondary }}>
|
||||
{email}
|
||||
</span>{" "}
|
||||
exists, you will get an email with instructions on resetting your
|
||||
password.
|
||||
</p>
|
||||
|
||||
<p
|
||||
css={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: 48,
|
||||
}}
|
||||
>
|
||||
Contact your deployment administrator if you encounter issues.
|
||||
</p>
|
||||
|
||||
<Button component={RouterLink} to="/login">
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
|
||||
export default RequestOTPPage;
|
||||
@@ -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(
|
||||
<Route element={<RoutesWithSuspense />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="setup" element={<SetupPage />} />
|
||||
<Route path="reset-password">
|
||||
<Route index element={<RequestOTPPage />} />
|
||||
<Route path="change" element={<ChangePasswordPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Dashboard routes */}
|
||||
<Route element={<RequireAuth />}>
|
||||
|
||||
Reference in New Issue
Block a user