Skip to content

Commit aaa1223

Browse files
1 parent 413928b commit aaa1223

14 files changed

+603
-44
lines changed

coderd/database/migrations/000266_update_forgot_password_notification.down.sql

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
UPDATE notification_templates
2+
SET
3+
title_template = E'Reset your password for Coder',
4+
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.',
5+
actions = '[{
6+
"label": "Reset password",
7+
"url": "{{ base_url }}/reset-password/change?otp={{.Labels.one_time_passcode}}&email={{ .UserEmail }}"
8+
}]'::jsonb
9+
WHERE
10+
id = '62f86a30-2330-4b61-a26d-311ff3b608cf'

coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden

+16-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
From: system@coder.com
22
To: bobby@coder.com
3-
Subject: Your One-Time Passcode for Coder.
3+
Subject: Reset your password for Coder
44
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
55
Date: Fri, 11 Oct 2024 09:03:06 +0000
66
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
@@ -12,13 +12,13 @@ Content-Type: text/plain; charset=UTF-8
1212

1313
Hi Bobby,
1414

15-
A request to reset the password for your Coder account has been made. Your =
16-
one-time passcode is:
15+
Use the link below to reset your password.
1716

18-
fad9020b-6562-4cdb-87f1-0486f1bea415
17+
If you did not make this request, you can ignore this message.
1918

20-
If you did not request to reset your password, you can ignore this message.
2119

20+
Reset password: http://test.com/reset-password/change?otp=3Dfad9020b-6562-4=
21+
cdb-87f1-0486f1bea415&email=3Dbobby@coder.com
2222

2323
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
2424
Content-Transfer-Encoding: quoted-printable
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
3030
<meta charset=3D"UTF-8" />
3131
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
3232
=3D1.0" />
33-
<title>Your One-Time Passcode for Coder.</title>
33+
<title>Reset your password for Coder</title>
3434
</head>
3535
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
3636
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
@@ -45,21 +45,24 @@ er Logo" style=3D"height: 40px;" />
4545
</div>
4646
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
4747
argin: 8px 0 32px; line-height: 1.5;">
48-
Your One-Time Passcode for Coder.
48+
Reset your password for Coder
4949
</h1>
5050
<div style=3D"line-height: 1.5;">
5151
<p>Hi Bobby,</p>
5252

53-
<p>A request to reset the password for your Coder account has been made. Yo=
54-
ur one-time passcode is:</p>
53+
<p>Use the link below to reset your password.</p>
5554

56-
<p><strong>fad9020b-6562-4cdb-87f1-0486f1bea415</strong></p>
57-
58-
<p>If you did not request to reset your password, you can ignore this messa=
59-
ge.</p>
55+
<p>If you did not make this request, you can ignore this message.</p>
6056
</div>
6157
<div style=3D"text-align: center; margin-top: 32px;">
6258
=20
59+
<a href=3D"http://test.com/reset-password/change?otp=3Dfad9020b-656=
60+
2-4cdb-87f1-0486f1bea415&email=3Dbobby@coder.com" style=3D"display: inline-=
61+
block; padding: 13px 24px; background-color: #020617; color: #f8fafc; text-=
62+
decoration: none; border-radius: 8px; margin: 0 4px;">
63+
Reset password
64+
</a>
65+
=20
6366
</div>
6467
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
6568
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">

coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden

+10-5
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@
99
"user_email": "bobby@coder.com",
1010
"user_name": "Bobby",
1111
"user_username": "bobby",
12-
"actions": [],
12+
"actions": [
13+
{
14+
"label": "Reset password",
15+
"url": "http://test.com/reset-password/change?otp=00000000-0000-0000-0000-000000000000\u0026email=bobby@coder.com"
16+
}
17+
],
1318
"labels": {
1419
"one_time_passcode": "00000000-0000-0000-0000-000000000000"
1520
},
1621
"data": null
1722
},
18-
"title": "Your One-Time Passcode for Coder.",
19-
"title_markdown": "Your One-Time Passcode for Coder.",
20-
"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.",
21-
"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."
23+
"title": "Reset your password for Coder",
24+
"title_markdown": "Reset your password for Coder",
25+
"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.",
26+
"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."
2227
}

site/src/api/api.ts

+12
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,18 @@ class ApiMethods {
21672167
);
21682168
return res.data;
21692169
};
2170+
2171+
requestOneTimePassword = async (
2172+
req: TypesGen.RequestOneTimePasscodeRequest,
2173+
) => {
2174+
await this.axios.post<void>("/api/v2/users/otp/request", req);
2175+
};
2176+
2177+
changePasswordWithOTP = async (
2178+
req: TypesGen.ChangePasswordWithOneTimePasscodeRequest,
2179+
) => {
2180+
await this.axios.post<void>("/api/v2/users/otp/change-password", req);
2181+
};
21702182
}
21712183

21722184
// This is a hard coded CSRF token/cookie pair for local development. In prod,

site/src/api/queries/users.ts

+14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
AuthorizationRequest,
44
GenerateAPIKeyResponse,
55
GetUsersResponse,
6+
RequestOneTimePasscodeRequest,
67
UpdateUserAppearanceSettingsRequest,
78
UpdateUserPasswordRequest,
89
UpdateUserProfileRequest,
@@ -253,3 +254,16 @@ export const updateAppearanceSettings = (
253254
},
254255
};
255256
};
257+
258+
export const requestOneTimePassword = () => {
259+
return {
260+
mutationFn: (req: RequestOneTimePasscodeRequest) =>
261+
API.requestOneTimePassword(req),
262+
};
263+
};
264+
265+
export const changePasswordWithOTP = () => {
266+
return {
267+
mutationFn: API.changePasswordWithOTP,
268+
};
269+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import { CoderIcon } from "components/Icons/CoderIcon";
3+
import type { FC } from "react";
4+
import { getApplicationName, getLogoURL } from "utils/appearance";
5+
6+
/**
7+
* Enterprise customers can set a custom logo for their Coder application. Use
8+
* the custom logo wherever the Coder logo is used, if a custom one is provided.
9+
*/
10+
export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
11+
const applicationName = getApplicationName();
12+
const logoURL = getLogoURL();
13+
14+
return logoURL ? (
15+
<img
16+
{...props}
17+
alt={applicationName}
18+
src={logoURL}
19+
// This prevent browser to display the ugly error icon if the
20+
// image path is wrong or user didn't finish typing the url
21+
onError={(e) => {
22+
e.currentTarget.style.display = "none";
23+
}}
24+
onLoad={(e) => {
25+
e.currentTarget.style.display = "inline";
26+
}}
27+
css={{ maxWidth: 200 }}
28+
className="application-logo"
29+
/>
30+
) : (
31+
<CoderIcon {...props} css={[{ fontSize: 64, fill: "white" }, props.css]} />
32+
);
33+
};

site/src/pages/LoginPage/LoginPageView.tsx

+2-26
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { Interpolation, Theme } from "@emotion/react";
22
import Button from "@mui/material/Button";
33
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
4-
import { CoderIcon } from "components/Icons/CoderIcon";
4+
import { CustomLogo } from "components/CustomLogo/CustomLogo";
55
import { Loader } from "components/Loader/Loader";
66
import { type FC, useState } from "react";
77
import { useLocation } from "react-router-dom";
8-
import { getApplicationName, getLogoURL } from "utils/appearance";
98
import { retrieveRedirect } from "utils/redirect";
109
import { SignInForm } from "./SignInForm";
1110
import { TermsOfServiceLink } from "./TermsOfServiceLink";
@@ -32,37 +31,14 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
3231
// This allows messages to be displayed at the top of the sign in form.
3332
// Helpful for any redirects that want to inform the user of something.
3433
const message = new URLSearchParams(location.search).get("message");
35-
const applicationName = getApplicationName();
36-
const logoURL = getLogoURL();
37-
const applicationLogo = logoURL ? (
38-
<img
39-
alt={applicationName}
40-
src={logoURL}
41-
// This prevent browser to display the ugly error icon if the
42-
// image path is wrong or user didn't finish typing the url
43-
onError={(e) => {
44-
e.currentTarget.style.display = "none";
45-
}}
46-
onLoad={(e) => {
47-
e.currentTarget.style.display = "inline";
48-
}}
49-
css={{
50-
maxWidth: "200px",
51-
}}
52-
className="application-logo"
53-
/>
54-
) : (
55-
<CoderIcon fill="white" opacity={1} css={styles.icon} />
56-
);
57-
5834
const [tosAccepted, setTosAccepted] = useState(false);
5935
const tosAcceptanceRequired =
6036
authMethods?.terms_of_service_url && !tosAccepted;
6137

6238
return (
6339
<div css={styles.root}>
6440
<div css={styles.container}>
65-
{applicationLogo}
41+
<CustomLogo />
6642
{isLoading ? (
6743
<Loader />
6844
) : tosAcceptanceRequired ? (

site/src/pages/LoginPage/PasswordSignInForm.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import LoadingButton from "@mui/lab/LoadingButton";
2+
import Link from "@mui/material/Link";
23
import TextField from "@mui/material/TextField";
34
import { Stack } from "components/Stack/Stack";
45
import { useFormik } from "formik";
56
import type { FC } from "react";
7+
import { Link as RouterLink } from "react-router-dom";
68
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
79
import * as Yup from "yup";
810
import { Language } from "./SignInForm";
@@ -65,6 +67,17 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
6567
>
6668
{Language.passwordSignIn}
6769
</LoadingButton>
70+
<Link
71+
component={RouterLink}
72+
to="/reset-password"
73+
css={{
74+
fontSize: 12,
75+
fontWeight: 500,
76+
lineHeight: "16px",
77+
}}
78+
>
79+
Forgot password?
80+
</Link>
6881
</Stack>
6982
</form>
7083
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, spyOn, userEvent, within } from "@storybook/test";
3+
import { API } from "api/api";
4+
import { mockApiError } from "testHelpers/entities";
5+
import { withGlobalSnackbar } from "testHelpers/storybook";
6+
import ChangePasswordPage from "./ChangePasswordPage";
7+
8+
const meta: Meta<typeof ChangePasswordPage> = {
9+
title: "pages/ResetPasswordPage/ChangePasswordPage",
10+
component: ChangePasswordPage,
11+
args: { redirect: false },
12+
decorators: [withGlobalSnackbar],
13+
};
14+
15+
export default meta;
16+
type Story = StoryObj<typeof ChangePasswordPage>;
17+
18+
export const Default: Story = {};
19+
20+
export const Success: Story = {
21+
play: async ({ canvasElement }) => {
22+
spyOn(API, "changePasswordWithOTP").mockResolvedValueOnce();
23+
const canvas = within(canvasElement);
24+
const user = userEvent.setup();
25+
const newPasswordInput = await canvas.findByLabelText("Password *");
26+
await user.type(newPasswordInput, "password");
27+
const confirmPasswordInput =
28+
await canvas.findByLabelText("Confirm password *");
29+
await user.type(confirmPasswordInput, "password");
30+
await user.click(canvas.getByRole("button", { name: /reset password/i }));
31+
await canvas.findByText("Password reset successfully");
32+
},
33+
};
34+
35+
export const WrongConfirmationPassword: Story = {
36+
play: async ({ canvasElement }) => {
37+
spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce(
38+
mockApiError({
39+
message: "New password should be different from the old password",
40+
}),
41+
);
42+
const canvas = within(canvasElement);
43+
const user = userEvent.setup();
44+
const newPasswordInput = await canvas.findByLabelText("Password *");
45+
await user.type(newPasswordInput, "password");
46+
const confirmPasswordInput =
47+
await canvas.findByLabelText("Confirm password *");
48+
await user.type(confirmPasswordInput, "different-password");
49+
await user.click(canvas.getByRole("button", { name: /reset password/i }));
50+
await canvas.findByText("Passwords must match");
51+
},
52+
};
53+
54+
export const ServerError: Story = {
55+
play: async ({ canvasElement }) => {
56+
const serverError =
57+
"New password should be different from the old password";
58+
spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce(
59+
mockApiError({
60+
message: serverError,
61+
}),
62+
);
63+
const canvas = within(canvasElement);
64+
const user = userEvent.setup();
65+
const newPasswordInput = await canvas.findByLabelText("Password *");
66+
await user.type(newPasswordInput, "password");
67+
const confirmPasswordInput =
68+
await canvas.findByLabelText("Confirm password *");
69+
await user.type(confirmPasswordInput, "password");
70+
await user.click(canvas.getByRole("button", { name: /reset password/i }));
71+
await canvas.findByText(serverError);
72+
},
73+
};

0 commit comments

Comments
 (0)