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 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