From dd9cfae9d81ecf858278bec7f358ed93814ec6c7 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 15 Oct 2024 16:35:23 +0000 Subject: [PATCH 01/11] Add request OTP flow --- site/src/api/api.ts | 6 + site/src/api/queries/users.ts | 6 + site/src/components/CustomLogo/CustomLogo.tsx | 31 ++++ site/src/pages/LoginPage/LoginPageView.tsx | 28 +-- .../pages/LoginPage/PasswordSignInForm.tsx | 13 ++ .../ResetPasswordPage/ResetPasswordPage.tsx | 174 ++++++++++++++++++ site/src/router.tsx | 4 + 7 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 site/src/components/CustomLogo/CustomLogo.tsx create mode 100644 site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 103a3c50e7900..cfbb5f45f4cc5 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2167,6 +2167,12 @@ class ApiMethods { ); return res.data; }; + + requestOneTimePassword = async ( + req: TypesGen.RequestOneTimePasscodeRequest, + ) => { + await this.axios.post("/api/v2/users/otp/request", 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..200463d8757b9 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -253,3 +253,9 @@ export const updateAppearanceSettings = ( }, }; }; + +export const requestOneTimePassword = () => { + return { + mutationFn: API.requestOneTimePassword, + }; +}; diff --git a/site/src/components/CustomLogo/CustomLogo.tsx b/site/src/components/CustomLogo/CustomLogo.tsx new file mode 100644 index 0000000000000..e77b7de0a7d9d --- /dev/null +++ b/site/src/components/CustomLogo/CustomLogo.tsx @@ -0,0 +1,31 @@ +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 = () => { + 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..d2844d1ffaaff 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -1,14 +1,13 @@ 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 { 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"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; export interface LoginPageViewProps { authMethods: AuthMethods | undefined; @@ -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..bf7adff0255b6 100644 --- a/site/src/pages/LoginPage/PasswordSignInForm.tsx +++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx @@ -6,6 +6,8 @@ import type { FC } from "react"; import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; import * as Yup from "yup"; import { Language } from "./SignInForm"; +import Link from "@mui/material/Link"; +import { Link as RouterLink } from "react-router-dom"; type PasswordSignInFormProps = { onSubmit: (credentials: { email: string; password: string }) => void; @@ -65,6 +67,17 @@ export const PasswordSignInForm: FC = ({ > {Language.passwordSignIn} + + Forgot password? + ); diff --git a/site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx b/site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx new file mode 100644 index 0000000000000..949da32e9fe74 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx @@ -0,0 +1,174 @@ +import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; +import { Button, TextField } from "@mui/material"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; +import { Stack } from "components/Stack/Stack"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { getApplicationName } from "utils/appearance"; +import { Link as RouterLink } from "react-router-dom"; +import { useMutation } from "react-query"; +import { requestOneTimePassword } from "api/queries/users"; +import { getErrorMessage } from "api/errors"; +import { displayError } from "components/GlobalSnackbar/utils"; + +const ResetPasswordPage: FC = () => { + const applicationName = getApplicationName(); + const requestOTPMutation = useMutation(requestOneTimePassword()); + + return ( + <> + + Reset Password - {applicationName} + + +
+
+ + {requestOTPMutation.isSuccess ? ( + + ) : ( + { + try { + await requestOTPMutation.mutateAsync({ email }); + } catch (error) { + displayError( + getErrorMessage(error, "Error requesting password change"), + ); + } + }} + /> + )} +
+
+ + ); +}; + +const RequestOTP: FC<{ + onRequest: (email: string) => Promise; + isRequesting: boolean; +}> = ({ onRequest, isRequesting }) => { + return ( + <> +

+ Enter your email to reset the password +

+
{ + e.preventDefault(); + const email = e.currentTarget.email.value; + await onRequest(email); + }} + > +
+ + + + + + Reset password + + + + +
+
+ + ); +}; + +const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => { + const theme = useTheme(); + + return ( +
+

We've sent a password reset link to the address below.

+ {email} +

+ Contact your deployment administrator if you encounter issues. +

+ +
+ ); +}; + +const styles = { + root: { + padding: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + minHeight: "100%", + textAlign: "center", + }, + container: { + width: "100%", + maxWidth: 320, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 16, + }, + icon: { + fontSize: 64, + }, + footer: (theme) => ({ + fontSize: 12, + color: theme.palette.text.secondary, + marginTop: 24, + }), +} satisfies Record>; + +export default ResetPasswordPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index 526be40a2b168..152a34237341c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -287,6 +287,9 @@ const DeploymentNotificationsPage = lazy( "./pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage" ), ); +const ResetPasswordPage = lazy( + () => import("./pages/ResetPasswordPage/ResetPasswordPage"), +); const RoutesWithSuspense = () => { return ( @@ -348,6 +351,7 @@ export const router = createBrowserRouter( }> } /> } /> + } /> {/* Dashboard routes */} }> From 648935a04c8a0021dcb47436e3519f446adbf08e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 16 Oct 2024 16:23:54 +0000 Subject: [PATCH 02/11] Add change password flow --- site/src/api/api.ts | 6 + site/src/api/queries/users.ts | 6 + .../ResetPasswordPage/ChangePasswordPage.tsx | 158 ++++++++++++++++++ ...setPasswordPage.tsx => RequestOTPPage.tsx} | 6 +- site/src/router.tsx | 12 +- 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx rename site/src/pages/ResetPasswordPage/{ResetPasswordPage.tsx => RequestOTPPage.tsx} (96%) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cfbb5f45f4cc5..7d87d9c8c2104 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2173,6 +2173,12 @@ class ApiMethods { ) => { 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 200463d8757b9..ebd79e6d5120e 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -259,3 +259,9 @@ export const requestOneTimePassword = () => { mutationFn: API.requestOneTimePassword, }; }; + +export const changePasswordWithOTP = () => { + return { + mutationFn: API.changePasswordWithOTP, + }; +}; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx new file mode 100644 index 0000000000000..0b235cae86a04 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -0,0 +1,158 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; +import { Button, TextField } from "@mui/material"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; +import { Stack } from "components/Stack/Stack"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { getApplicationName } from "utils/appearance"; +import { + Link as RouterLink, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { useMutation } from "react-query"; +import { changePasswordWithOTP } from "api/queries/users"; +import { getErrorMessage } from "api/errors"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { useFormik } from "formik"; +import * as yup from "yup"; +import { getFormHelpers } from "utils/formUtils"; + +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; + }), +}); + +const ChangePasswordPage: FC = () => { + 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") ?? ""; + + try { + await changePasswordMutation.mutateAsync({ + email, + one_time_passcode: otp, + password: values.password, + }); + displaySuccess("Password reset successfully"); + navigate("/login"); + } catch (error) { + displayError(getErrorMessage(error, "Error resetting password")); + } + }, + }); + const getFieldHelpers = getFormHelpers(form); + + return ( + <> + + Reset Password - {applicationName} + + +
+
+ +

+ Choose a new password +

+
+
+ + + + + + + + Reset password + + + + +
+
+
+
+ + ); +}; + +const styles = { + root: { + padding: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + minHeight: "100%", + textAlign: "center", + }, + container: { + width: "100%", + maxWidth: 320, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 16, + }, + 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/ResetPasswordPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx similarity index 96% rename from site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx rename to site/src/pages/ResetPasswordPage/RequestOTPPage.tsx index 949da32e9fe74..d0522b8e6eaa6 100644 --- a/site/src/pages/ResetPasswordPage/ResetPasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -12,14 +12,14 @@ import { requestOneTimePassword } from "api/queries/users"; import { getErrorMessage } from "api/errors"; import { displayError } from "components/GlobalSnackbar/utils"; -const ResetPasswordPage: FC = () => { +const RequestOTPPage: FC = () => { const applicationName = getApplicationName(); const requestOTPMutation = useMutation(requestOneTimePassword()); return ( <> - Reset Password - {applicationName} + Request Password Reset - {applicationName}
@@ -171,4 +171,4 @@ const styles = { }), } satisfies Record>; -export default ResetPasswordPage; +export default RequestOTPPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index 152a34237341c..2531c823b9f48 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -287,8 +287,11 @@ const DeploymentNotificationsPage = lazy( "./pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage" ), ); -const ResetPasswordPage = lazy( - () => import("./pages/ResetPasswordPage/ResetPasswordPage"), +const RequestOTPPage = lazy( + () => import("./pages/ResetPasswordPage/RequestOTPPage"), +); +const ChangePasswordPage = lazy( + () => import("./pages/ResetPasswordPage/ChangePasswordPage"), ); const RoutesWithSuspense = () => { @@ -351,7 +354,10 @@ export const router = createBrowserRouter( }> } /> } /> - } /> + + } /> + } /> + {/* Dashboard routes */} }> From c1253209af211d81a222ba9dcf13f5b08ff6e8f6 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 16 Oct 2024 17:02:39 +0000 Subject: [PATCH 03/11] Update email template --- .../dbmock/gomock_reflect_1559767992/prog.go | 67 +++++++++++++++++++ ...date_forgot_password_notification.down.sql | 0 ...update_forgot_password_notification.up.sql | 10 +++ .../psmock/gomock_reflect_2036580849/prog.go | 67 +++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 coderd/database/dbmock/gomock_reflect_1559767992/prog.go create mode 100644 coderd/database/migrations/000264_update_forgot_password_notification.down.sql create mode 100644 coderd/database/migrations/000264_update_forgot_password_notification.up.sql create mode 100644 coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go diff --git a/coderd/database/dbmock/gomock_reflect_1559767992/prog.go b/coderd/database/dbmock/gomock_reflect_1559767992/prog.go new file mode 100644 index 0000000000000..e6031157e9476 --- /dev/null +++ b/coderd/database/dbmock/gomock_reflect_1559767992/prog.go @@ -0,0 +1,67 @@ + +// Code generated by MockGen. DO NOT EDIT. +package main + +import ( + "encoding/gob" + "flag" + "fmt" + "os" + "path" + "reflect" + + "go.uber.org/mock/mockgen/model" + + pkg_ "github.com/coder/coder/v2/coderd/database" +) + +var output = flag.String("output", "", "The output file name, or empty to use stdout.") + +func main() { + flag.Parse() + + its := []struct{ + sym string + typ reflect.Type + }{ + + { "Store", reflect.TypeOf((*pkg_.Store)(nil)).Elem()}, + + } + pkg := &model.Package{ + // NOTE: This behaves contrary to documented behaviour if the + // package name is not the final component of the import path. + // The reflect package doesn't expose the package name, though. + Name: path.Base("github.com/coder/coder/v2/coderd/database"), + } + + for _, it := range its { + intf, err := model.InterfaceFromInterfaceType(it.typ) + if err != nil { + fmt.Fprintf(os.Stderr, "Reflection: %v\n", err) + os.Exit(1) + } + intf.Name = it.sym + pkg.Interfaces = append(pkg.Interfaces, intf) + } + + outfile := os.Stdout + if len(*output) != 0 { + var err error + outfile, err = os.Create(*output) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open output file %q", *output) + } + defer func() { + if err := outfile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close output file %q", *output) + os.Exit(1) + } + }() + } + + if err := gob.NewEncoder(outfile).Encode(pkg); err != nil { + fmt.Fprintf(os.Stderr, "gob encode: %v\n", err) + os.Exit(1) + } +} diff --git a/coderd/database/migrations/000264_update_forgot_password_notification.down.sql b/coderd/database/migrations/000264_update_forgot_password_notification.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000264_update_forgot_password_notification.up.sql b/coderd/database/migrations/000264_update_forgot_password_notification.up.sql new file mode 100644 index 0000000000000..284b9cbee5561 --- /dev/null +++ b/coderd/database/migrations/000264_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\nA request to reset the password for your Coder account has been made.\n\nIf you did not request to reset your password, 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/database/pubsub/psmock/gomock_reflect_2036580849/prog.go b/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go new file mode 100644 index 0000000000000..31b98a5c26b6e --- /dev/null +++ b/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go @@ -0,0 +1,67 @@ + +// Code generated by MockGen. DO NOT EDIT. +package main + +import ( + "encoding/gob" + "flag" + "fmt" + "os" + "path" + "reflect" + + "go.uber.org/mock/mockgen/model" + + pkg_ "github.com/coder/coder/v2/coderd/database/pubsub" +) + +var output = flag.String("output", "", "The output file name, or empty to use stdout.") + +func main() { + flag.Parse() + + its := []struct{ + sym string + typ reflect.Type + }{ + + { "Pubsub", reflect.TypeOf((*pkg_.Pubsub)(nil)).Elem()}, + + } + pkg := &model.Package{ + // NOTE: This behaves contrary to documented behaviour if the + // package name is not the final component of the import path. + // The reflect package doesn't expose the package name, though. + Name: path.Base("github.com/coder/coder/v2/coderd/database/pubsub"), + } + + for _, it := range its { + intf, err := model.InterfaceFromInterfaceType(it.typ) + if err != nil { + fmt.Fprintf(os.Stderr, "Reflection: %v\n", err) + os.Exit(1) + } + intf.Name = it.sym + pkg.Interfaces = append(pkg.Interfaces, intf) + } + + outfile := os.Stdout + if len(*output) != 0 { + var err error + outfile, err = os.Create(*output) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open output file %q", *output) + } + defer func() { + if err := outfile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close output file %q", *output) + os.Exit(1) + } + }() + } + + if err := gob.NewEncoder(outfile).Encode(pkg); err != nil { + fmt.Fprintf(os.Stderr, "gob encode: %v\n", err) + os.Exit(1) + } +} From 893efec82b90f95afc1f5e77250b6f3d974a4da6 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 16 Oct 2024 17:13:06 +0000 Subject: [PATCH 04/11] Fix lint --- site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx | 3 ++- site/src/pages/ResetPasswordPage/RequestOTPPage.tsx | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx index 0b235cae86a04..d42c421097517 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -1,6 +1,7 @@ import type { Interpolation, Theme } from "@emotion/react"; import LoadingButton from "@mui/lab/LoadingButton"; -import { Button, TextField } from "@mui/material"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; import { CustomLogo } from "components/CustomLogo/CustomLogo"; import { Stack } from "components/Stack/Stack"; import type { FC } from "react"; diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx index d0522b8e6eaa6..fb4563b433dc7 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -1,6 +1,7 @@ import { useTheme, type Interpolation, type Theme } from "@emotion/react"; import LoadingButton from "@mui/lab/LoadingButton"; -import { Button, TextField } from "@mui/material"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; import { CustomLogo } from "components/CustomLogo/CustomLogo"; import { Stack } from "components/Stack/Stack"; import type { FC } from "react"; @@ -27,10 +28,7 @@ const RequestOTPPage: FC = () => { {requestOTPMutation.isSuccess ? ( ) : ( Date: Wed, 16 Oct 2024 18:39:51 +0000 Subject: [PATCH 05/11] Add tests --- site/src/api/queries/users.ts | 4 +- .../ChangePasswordPage.stories.tsx | 76 +++++++++++++++++++ .../ResetPasswordPage/ChangePasswordPage.tsx | 12 ++- .../RequestOTPPage.stories.tsx | 43 +++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx create mode 100644 site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index ebd79e6d5120e..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, @@ -256,7 +257,8 @@ export const updateAppearanceSettings = ( export const requestOneTimePassword = () => { return { - mutationFn: API.requestOneTimePassword, + mutationFn: (req: RequestOneTimePasscodeRequest) => + API.requestOneTimePassword(req), }; }; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx new file mode 100644 index 0000000000000..b55ad33a5ff82 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ChangePasswordPage from "./ChangePasswordPage"; +import { spyOn, userEvent, within, expect } from "@storybook/test"; +import { API } from "api/api"; +import { mockApiError } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; + +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("New password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = await canvas.findByLabelText( + "Confirm new 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("New password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = await canvas.findByLabelText( + "Confirm new 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("New password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = await canvas.findByLabelText( + "Confirm new 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 index d42c421097517..6b7923cfd5aae 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -30,7 +30,13 @@ const validationSchema = yup.object({ }), }); -const ChangePasswordPage: FC = () => { +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()); @@ -54,7 +60,9 @@ const ChangePasswordPage: FC = () => { password: values.password, }); displaySuccess("Password reset successfully"); - navigate("/login"); + if (redirect) { + navigate("/login"); + } } catch (error) { displayError(getErrorMessage(error, "Error resetting password")); } diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx new file mode 100644 index 0000000000000..24f992cbc4923 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import RequestOTPPage from "./RequestOTPPage"; +import { spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import { mockApiError } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; + +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 })); + }, +}; From 1c4bdee6670cc883d3b990c87016499df6399b82 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 16 Oct 2024 18:40:23 +0000 Subject: [PATCH 06/11] Fix fmt --- .../dbmock/gomock_reflect_1559767992/prog.go | 8 +++----- .../psmock/gomock_reflect_2036580849/prog.go | 8 +++----- site/src/pages/LoginPage/LoginPageView.tsx | 2 +- site/src/pages/LoginPage/PasswordSignInForm.tsx | 4 ++-- .../ChangePasswordPage.stories.tsx | 4 ++-- .../pages/ResetPasswordPage/ChangePasswordPage.tsx | 14 +++++++------- .../ResetPasswordPage/RequestOTPPage.stories.tsx | 2 +- .../src/pages/ResetPasswordPage/RequestOTPPage.tsx | 12 ++++++------ 8 files changed, 25 insertions(+), 29 deletions(-) diff --git a/coderd/database/dbmock/gomock_reflect_1559767992/prog.go b/coderd/database/dbmock/gomock_reflect_1559767992/prog.go index e6031157e9476..55382d35c0e87 100644 --- a/coderd/database/dbmock/gomock_reflect_1559767992/prog.go +++ b/coderd/database/dbmock/gomock_reflect_1559767992/prog.go @@ -1,4 +1,3 @@ - // Code generated by MockGen. DO NOT EDIT. package main @@ -20,13 +19,12 @@ var output = flag.String("output", "", "The output file name, or empty to use st func main() { flag.Parse() - its := []struct{ + its := []struct { sym string typ reflect.Type }{ - - { "Store", reflect.TypeOf((*pkg_.Store)(nil)).Elem()}, - + + {"Store", reflect.TypeOf((*pkg_.Store)(nil)).Elem()}, } pkg := &model.Package{ // NOTE: This behaves contrary to documented behaviour if the diff --git a/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go b/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go index 31b98a5c26b6e..182e74b4b4f3d 100644 --- a/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go +++ b/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go @@ -1,4 +1,3 @@ - // Code generated by MockGen. DO NOT EDIT. package main @@ -20,13 +19,12 @@ var output = flag.String("output", "", "The output file name, or empty to use st func main() { flag.Parse() - its := []struct{ + its := []struct { sym string typ reflect.Type }{ - - { "Pubsub", reflect.TypeOf((*pkg_.Pubsub)(nil)).Elem()}, - + + {"Pubsub", reflect.TypeOf((*pkg_.Pubsub)(nil)).Elem()}, } pkg := &model.Package{ // NOTE: This behaves contrary to documented behaviour if the diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index d2844d1ffaaff..9404722431583 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -1,13 +1,13 @@ import type { Interpolation, Theme } from "@emotion/react"; import Button from "@mui/material/Button"; import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated"; +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 { retrieveRedirect } from "utils/redirect"; import { SignInForm } from "./SignInForm"; import { TermsOfServiceLink } from "./TermsOfServiceLink"; -import { CustomLogo } from "components/CustomLogo/CustomLogo"; export interface LoginPageViewProps { authMethods: AuthMethods | undefined; diff --git a/site/src/pages/LoginPage/PasswordSignInForm.tsx b/site/src/pages/LoginPage/PasswordSignInForm.tsx index bf7adff0255b6..e2ca4dc5bcfaa 100644 --- a/site/src/pages/LoginPage/PasswordSignInForm.tsx +++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx @@ -1,13 +1,13 @@ 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"; -import Link from "@mui/material/Link"; -import { Link as RouterLink } from "react-router-dom"; type PasswordSignInFormProps = { onSubmit: (credentials: { email: string; password: string }) => void; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx index b55ad33a5ff82..8dbc56852a401 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react"; -import ChangePasswordPage from "./ChangePasswordPage"; -import { spyOn, userEvent, within, expect } from "@storybook/test"; +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", diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx index 6b7923cfd5aae..2a394bd541492 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -2,23 +2,23 @@ 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 { getErrorMessage } from "api/errors"; +import { changePasswordWithOTP } from "api/queries/users"; import { CustomLogo } from "components/CustomLogo/CustomLogo"; +import { displayError, 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 { getApplicationName } from "utils/appearance"; +import { useMutation } from "react-query"; import { Link as RouterLink, useNavigate, useSearchParams, } from "react-router-dom"; -import { useMutation } from "react-query"; -import { changePasswordWithOTP } from "api/queries/users"; -import { getErrorMessage } from "api/errors"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { useFormik } from "formik"; -import * as yup from "yup"; +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"), diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx index 24f992cbc4923..5f75f607ab9d3 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react"; -import RequestOTPPage from "./RequestOTPPage"; 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", diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx index fb4563b433dc7..30ab5f129ce62 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -1,17 +1,17 @@ -import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +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 { 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 { getApplicationName } from "utils/appearance"; -import { Link as RouterLink } from "react-router-dom"; import { useMutation } from "react-query"; -import { requestOneTimePassword } from "api/queries/users"; -import { getErrorMessage } from "api/errors"; -import { displayError } from "components/GlobalSnackbar/utils"; +import { Link as RouterLink } from "react-router-dom"; +import { getApplicationName } from "utils/appearance"; const RequestOTPPage: FC = () => { const applicationName = getApplicationName(); From 4d7ec36b9ff3f5bd11aaf14ffbda687ba1e32e37 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 17 Oct 2024 13:55:44 +0000 Subject: [PATCH 07/11] Remove unecessary files --- .../dbmock/gomock_reflect_1559767992/prog.go | 65 ------------------- .../psmock/gomock_reflect_2036580849/prog.go | 65 ------------------- 2 files changed, 130 deletions(-) delete mode 100644 coderd/database/dbmock/gomock_reflect_1559767992/prog.go delete mode 100644 coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go diff --git a/coderd/database/dbmock/gomock_reflect_1559767992/prog.go b/coderd/database/dbmock/gomock_reflect_1559767992/prog.go deleted file mode 100644 index 55382d35c0e87..0000000000000 --- a/coderd/database/dbmock/gomock_reflect_1559767992/prog.go +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -package main - -import ( - "encoding/gob" - "flag" - "fmt" - "os" - "path" - "reflect" - - "go.uber.org/mock/mockgen/model" - - pkg_ "github.com/coder/coder/v2/coderd/database" -) - -var output = flag.String("output", "", "The output file name, or empty to use stdout.") - -func main() { - flag.Parse() - - its := []struct { - sym string - typ reflect.Type - }{ - - {"Store", reflect.TypeOf((*pkg_.Store)(nil)).Elem()}, - } - pkg := &model.Package{ - // NOTE: This behaves contrary to documented behaviour if the - // package name is not the final component of the import path. - // The reflect package doesn't expose the package name, though. - Name: path.Base("github.com/coder/coder/v2/coderd/database"), - } - - for _, it := range its { - intf, err := model.InterfaceFromInterfaceType(it.typ) - if err != nil { - fmt.Fprintf(os.Stderr, "Reflection: %v\n", err) - os.Exit(1) - } - intf.Name = it.sym - pkg.Interfaces = append(pkg.Interfaces, intf) - } - - outfile := os.Stdout - if len(*output) != 0 { - var err error - outfile, err = os.Create(*output) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open output file %q", *output) - } - defer func() { - if err := outfile.Close(); err != nil { - fmt.Fprintf(os.Stderr, "failed to close output file %q", *output) - os.Exit(1) - } - }() - } - - if err := gob.NewEncoder(outfile).Encode(pkg); err != nil { - fmt.Fprintf(os.Stderr, "gob encode: %v\n", err) - os.Exit(1) - } -} diff --git a/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go b/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go deleted file mode 100644 index 182e74b4b4f3d..0000000000000 --- a/coderd/database/pubsub/psmock/gomock_reflect_2036580849/prog.go +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -package main - -import ( - "encoding/gob" - "flag" - "fmt" - "os" - "path" - "reflect" - - "go.uber.org/mock/mockgen/model" - - pkg_ "github.com/coder/coder/v2/coderd/database/pubsub" -) - -var output = flag.String("output", "", "The output file name, or empty to use stdout.") - -func main() { - flag.Parse() - - its := []struct { - sym string - typ reflect.Type - }{ - - {"Pubsub", reflect.TypeOf((*pkg_.Pubsub)(nil)).Elem()}, - } - pkg := &model.Package{ - // NOTE: This behaves contrary to documented behaviour if the - // package name is not the final component of the import path. - // The reflect package doesn't expose the package name, though. - Name: path.Base("github.com/coder/coder/v2/coderd/database/pubsub"), - } - - for _, it := range its { - intf, err := model.InterfaceFromInterfaceType(it.typ) - if err != nil { - fmt.Fprintf(os.Stderr, "Reflection: %v\n", err) - os.Exit(1) - } - intf.Name = it.sym - pkg.Interfaces = append(pkg.Interfaces, intf) - } - - outfile := os.Stdout - if len(*output) != 0 { - var err error - outfile, err = os.Create(*output) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open output file %q", *output) - } - defer func() { - if err := outfile.Close(); err != nil { - fmt.Fprintf(os.Stderr, "failed to close output file %q", *output) - os.Exit(1) - } - }() - } - - if err := gob.NewEncoder(outfile).Encode(pkg); err != nil { - fmt.Fprintf(os.Stderr, "gob encode: %v\n", err) - os.Exit(1) - } -} From 4b22b4d51d6d719c8644e3c933c12f8e93b60bb1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 17 Oct 2024 13:55:58 +0000 Subject: [PATCH 08/11] Fix migration numbers --- ...wn.sql => 000266_update_forgot_password_notification.down.sql} | 0 ...n.up.sql => 000266_update_forgot_password_notification.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000264_update_forgot_password_notification.down.sql => 000266_update_forgot_password_notification.down.sql} (100%) rename coderd/database/migrations/{000264_update_forgot_password_notification.up.sql => 000266_update_forgot_password_notification.up.sql} (100%) diff --git a/coderd/database/migrations/000264_update_forgot_password_notification.down.sql b/coderd/database/migrations/000266_update_forgot_password_notification.down.sql similarity index 100% rename from coderd/database/migrations/000264_update_forgot_password_notification.down.sql rename to coderd/database/migrations/000266_update_forgot_password_notification.down.sql diff --git a/coderd/database/migrations/000264_update_forgot_password_notification.up.sql b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql similarity index 100% rename from coderd/database/migrations/000264_update_forgot_password_notification.up.sql rename to coderd/database/migrations/000266_update_forgot_password_notification.up.sql From 7ef3df8d266d11c46005668846f0d7319a80b303 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 17 Oct 2024 14:34:31 +0000 Subject: [PATCH 09/11] Fix styles --- site/src/components/CustomLogo/CustomLogo.tsx | 6 +- .../ChangePasswordPage.stories.tsx | 21 +- .../ResetPasswordPage/ChangePasswordPage.tsx | 45 ++-- .../ResetPasswordPage/RequestOTPPage.tsx | 211 ++++++++++-------- 4 files changed, 155 insertions(+), 128 deletions(-) diff --git a/site/src/components/CustomLogo/CustomLogo.tsx b/site/src/components/CustomLogo/CustomLogo.tsx index e77b7de0a7d9d..e207e8fac27b9 100644 --- a/site/src/components/CustomLogo/CustomLogo.tsx +++ b/site/src/components/CustomLogo/CustomLogo.tsx @@ -1,3 +1,4 @@ +import type { Interpolation, Theme } from "@emotion/react"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { FC } from "react"; import { getApplicationName, getLogoURL } from "utils/appearance"; @@ -6,12 +7,13 @@ 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 = () => { +export const CustomLogo: FC<{ css?: Interpolation }> = (props) => { const applicationName = getApplicationName(); const logoURL = getLogoURL(); return logoURL ? ( {applicationName} { className="application-logo" /> ) : ( - + ); }; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx index 8dbc56852a401..d59ead3a59579 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -22,11 +22,10 @@ export const Success: Story = { spyOn(API, "changePasswordWithOTP").mockResolvedValueOnce(); const canvas = within(canvasElement); const user = userEvent.setup(); - const newPasswordInput = await canvas.findByLabelText("New password *"); + const newPasswordInput = await canvas.findByLabelText("Password *"); await user.type(newPasswordInput, "password"); - const confirmPasswordInput = await canvas.findByLabelText( - "Confirm new 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"); @@ -42,11 +41,10 @@ export const WrongConfirmationPassword: Story = { ); const canvas = within(canvasElement); const user = userEvent.setup(); - const newPasswordInput = await canvas.findByLabelText("New password *"); + const newPasswordInput = await canvas.findByLabelText("Password *"); await user.type(newPasswordInput, "password"); - const confirmPasswordInput = await canvas.findByLabelText( - "Confirm new 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"); @@ -64,11 +62,10 @@ export const ServerError: Story = { ); const canvas = within(canvasElement); const user = userEvent.setup(); - const newPasswordInput = await canvas.findByLabelText("New password *"); + const newPasswordInput = await canvas.findByLabelText("Password *"); await user.type(newPasswordInput, "password"); - const confirmPasswordInput = await canvas.findByLabelText( - "Confirm new 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 index 2a394bd541492..077bc39da82d4 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -2,10 +2,10 @@ 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 { getErrorMessage } from "api/errors"; import { changePasswordWithOTP } from "api/queries/users"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { CustomLogo } from "components/CustomLogo/CustomLogo"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { useFormik } from "formik"; import type { FC } from "react"; @@ -53,18 +53,14 @@ const ChangePasswordPage: FC = ({ redirect }) => { const email = searchParams.get("email") ?? ""; const otp = searchParams.get("otp") ?? ""; - try { - await changePasswordMutation.mutateAsync({ - email, - one_time_passcode: otp, - password: values.password, - }); - displaySuccess("Password reset successfully"); - if (redirect) { - navigate("/login"); - } - } catch (error) { - displayError(getErrorMessage(error, "Error resetting password")); + await changePasswordMutation.mutateAsync({ + email, + one_time_passcode: otp, + password: values.password, + }); + displaySuccess("Password reset successfully"); + if (redirect) { + navigate("/login"); } }, }); @@ -78,9 +74,11 @@ const ChangePasswordPage: FC = ({ redirect }) => {
- +

= ({ redirect }) => { > Choose a new password

+ {changePasswordMutation.error ? ( + + ) : null}
= ({ redirect }) => { /> = ({ redirect }) => { variant="text" to="/login" > - Cancel + Back to login @@ -138,11 +142,15 @@ const ChangePasswordPage: FC = ({ redirect }) => { }; const styles = { + logo: { + marginBottom: 40, + }, root: { padding: 24, display: "flex", alignItems: "center", justifyContent: "center", + flexDirection: "column", minHeight: "100%", textAlign: "center", }, @@ -152,7 +160,6 @@ const styles = { display: "flex", flexDirection: "column", alignItems: "center", - gap: 16, }, icon: { fontSize: 64, diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx index 30ab5f129ce62..0a097971b6626 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -4,6 +4,7 @@ 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"; @@ -20,94 +21,99 @@ const RequestOTPPage: FC = () => { return ( <> - Request Password Reset - {applicationName} + Reset Password - {applicationName} -
-
- - {requestOTPMutation.isSuccess ? ( - - ) : ( - { - try { - await requestOTPMutation.mutateAsync({ email }); - } catch (error) { - displayError( - getErrorMessage(error, "Error requesting password change"), - ); - } - }} - /> - )} -
-
+
+ + {requestOTPMutation.isSuccess ? ( + + ) : ( + { + requestOTPMutation.mutate({ email }); + }} + /> + )} +
); }; -const RequestOTP: FC<{ - onRequest: (email: string) => Promise; +type RequestOTPProps = { + error: unknown; + onRequest: (email: string) => void; isRequesting: boolean; -}> = ({ onRequest, isRequesting }) => { - return ( - <> -

- Enter your email to reset the password -

- { - e.preventDefault(); - const email = e.currentTarget.email.value; - await onRequest(email); - }} - > -
- - +}; - - - Reset password - - + /> + + + + Reset password + + + - -
- - +
+ +
+
); }; @@ -117,37 +123,53 @@ const RequestOTPSuccess: FC<{ email: string }> = ({ email }) => { return (
-

We've sent a password reset link to the address below.

- {email} -

- Contact your deployment administrator if you encounter issues. -

- +
+

+ 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", }, @@ -157,7 +179,6 @@ const styles = { display: "flex", flexDirection: "column", alignItems: "center", - gap: 16, }, icon: { fontSize: 64, From c0d3a6c11a83e415b1b3e54b5bd37c3efab4bd84 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 17 Oct 2024 18:03:26 +0000 Subject: [PATCH 10/11] Update golden files --- ...teUserRequestedOneTimePasscode.html.golden | 27 +++++++++++-------- ...teUserRequestedOneTimePasscode.json.golden | 15 +++++++---- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 2b61765813bcf..979ad375b875d 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,14 +12,14 @@ 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: - -fad9020b-6562-4cdb-87f1-0486f1bea415 +A request to reset the password for your Coder account has been made. 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 Content-Type: text/html; charset=UTF-8 @@ -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:

- -

fad9020b-6562-4cdb-87f1-0486f1bea415

+

A request to reset the password for your Coder account has been made.

If you did not request to reset your password, you can ignore this messa= ge.

diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden index 2c03fc7c71905..2f92f71f4d9aa 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\nA request to reset the password for your Coder account has been made.\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.\n\nIf you did not request to reset your password, you can ignore this message." } \ No newline at end of file From c2797043b4f4392d670151fe546fefa5ff29a405 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 18 Oct 2024 12:38:17 +0000 Subject: [PATCH 11/11] Update message with @stirby copy --- .../000266_update_forgot_password_notification.up.sql | 2 +- .../TemplateUserRequestedOneTimePasscode.html.golden | 10 ++++------ .../TemplateUserRequestedOneTimePasscode.json.golden | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/coderd/database/migrations/000266_update_forgot_password_notification.up.sql b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql index 284b9cbee5561..d7d6e5f176efc 100644 --- a/coderd/database/migrations/000266_update_forgot_password_notification.up.sql +++ b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql @@ -1,7 +1,7 @@ UPDATE notification_templates SET title_template = E'Reset your password for Coder', - body_template = E'Hi {{.UserName}},\n\nA request to reset the password for your Coder account has been made.\n\nIf you did not request to reset your password, you can ignore this message.', + 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 }}" diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 979ad375b875d..fc74699e70afd 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden @@ -12,9 +12,9 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -A request to reset the password for your Coder account has been made. +Use the link below to reset your password. -If you did not request to reset your password, you can ignore this message. +If you did not make this request, you can ignore this message. Reset password: http://test.com/reset-password/change?otp=3Dfad9020b-6562-4= @@ -50,11 +50,9 @@ argin: 8px 0 32px; line-height: 1.5;">

Hi Bobby,

-

A request to reset the password for your Coder account has been made. +

Use the link below to reset your password.

-

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 diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden index 2f92f71f4d9aa..b3610b6661da6 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden @@ -22,6 +22,6 @@ }, "title": "Reset your password for Coder", "title_markdown": "Reset your password for Coder", - "body": "Hi Bobby,\n\nA request to reset the password for your Coder account has been made.\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.\n\nIf you did not request to reset your password, you can ignore this message." + "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