feat(site): add forgot password link (#15108)

Demo:

https://github.com/user-attachments/assets/139eb8c0-5bd6-4bbd-8064-a4acc526afda
This commit is contained in:
Bruno Quaresma
2024-10-18 09:50:22 -03:00
committed by GitHub
parent 413928b57a
commit aaa1223408
14 changed files with 603 additions and 44 deletions
@@ -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'
@@ -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;">
@@ -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."
}
+12
View File
@@ -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,
+14
View File
@@ -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]} />
);
};
+2 -26
View File
@@ -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;
+10
View File
@@ -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 />}>