Skip to content

feat(site): add forgot password link #15108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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=
Expand All @@ -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;">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 12 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions site/src/api/queries/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
AuthorizationRequest,
GenerateAPIKeyResponse,
GetUsersResponse,
RequestOneTimePasscodeRequest,
UpdateUserAppearanceSettingsRequest,
UpdateUserPasswordRequest,
UpdateUserProfileRequest,
Expand Down Expand Up @@ -253,3 +254,16 @@ export const updateAppearanceSettings = (
},
};
};

export const requestOneTimePassword = () => {
return {
mutationFn: (req: RequestOneTimePasscodeRequest) =>
API.requestOneTimePassword(req),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Could we pass the function directly as below?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, when we do that, we are not able to spy on the method. I'm not sure why, but we have encountered this issue in jest as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weeeeird.

};
};

export const changePasswordWithOTP = () => {
return {
mutationFn: API.changePasswordWithOTP,
};
};
33 changes: 33 additions & 0 deletions site/src/components/CustomLogo/CustomLogo.tsx
Original file line number Diff line number Diff line change
@@ -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]} />
);
};
28 changes: 2 additions & 26 deletions site/src/pages/LoginPage/LoginPageView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -32,37 +31,14 @@ 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;

return (
<div css={styles.root}>
<div css={styles.container}>
{applicationLogo}
<CustomLogo />
{isLoading ? (
<Loader />
) : tosAcceptanceRequired ? (
Expand Down
13 changes: 13 additions & 0 deletions site/src/pages/LoginPage/PasswordSignInForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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>
);
Expand Down
73 changes: 73 additions & 0 deletions site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -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);
},
};
Loading
Loading