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 0000000000000..e69de29bb2d1d 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 0000000000000..d7d6e5f176efc --- /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 2b61765813bcf..fc74699e70afd 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.

=20 + + Reset password + + =20
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden index 2c03fc7c71905..b3610b6661da6 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 103a3c50e7900..7d87d9c8c2104 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 427054b3fe5e2..833d88e6baeef 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 0000000000000..e207e8fac27b9 --- /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 ? ( + {applicationName} { + 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 8b9a5ec472554..9404722431583 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 ? ( - {applicationName} { - 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 d1e7ab9194f6f..e2ca4dc5bcfaa 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 0000000000000..d59ead3a59579 --- /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 0000000000000..077bc39da82d4 --- /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} +
+
+ + + + + + + + Reset password + + + + +
+
+
+
+ + ); +}; + +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 0000000000000..5f75f607ab9d3 --- /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 0000000000000..0a097971b6626 --- /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} +
{ + e.preventDefault(); + const email = e.currentTarget.email.value; + onRequest(email); + }} + > +
+ + + + + + Reset password + + + + +
+
+
+
+ ); +}; + +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 526be40a2b168..2531c823b9f48 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 */} }> pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy