diff --git a/coderd/database/migrations/000266_update_forgot_password_notification.down.sql b/coderd/database/migrations/000266_update_forgot_password_notification.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000266_update_forgot_password_notification.up.sql b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql new file mode 100644 index 0000000000000..d7d6e5f176efc --- /dev/null +++ b/coderd/database/migrations/000266_update_forgot_password_notification.up.sql @@ -0,0 +1,10 @@ +UPDATE notification_templates +SET + title_template = E'Reset your password for Coder', + body_template = E'Hi {{.UserName}},\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message.', + actions = '[{ + "label": "Reset password", + "url": "{{ base_url }}/reset-password/change?otp={{.Labels.one_time_passcode}}&email={{ .UserEmail }}" + }]'::jsonb +WHERE + id = '62f86a30-2330-4b61-a26d-311ff3b608cf' diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 2b61765813bcf..fc74699e70afd 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden @@ -1,6 +1,6 @@ From: system@coder.com To: bobby@coder.com -Subject: Your One-Time Passcode for Coder. +Subject: Reset your password for Coder Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 Date: Fri, 11 Oct 2024 09:03:06 +0000 Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 @@ -12,13 +12,13 @@ Content-Type: text/plain; charset=UTF-8 Hi Bobby, -A request to reset the password for your Coder account has been made. Your = -one-time passcode is: +Use the link below to reset your password. -fad9020b-6562-4cdb-87f1-0486f1bea415 +If you did not make this request, you can ignore this message. -If you did not request to reset your password, you can ignore this message. +Reset password: http://test.com/reset-password/change?otp=3Dfad9020b-6562-4= +cdb-87f1-0486f1bea415&email=3Dbobby@coder.com --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 Content-Transfer-Encoding: quoted-printable @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - Your One-Time Passcode for Coder. + Reset your password for Coder

- Your One-Time Passcode for Coder. + Reset your password for Coder

Hi Bobby,

-

A request to reset the password for your Coder account has been made. Yo= -ur one-time passcode is:

+

Use the link below to reset your password.

-

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

- -

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

+

If you did not make this request, you can ignore this message.

=20 + + Reset password + + =20
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden index 2c03fc7c71905..b3610b6661da6 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserRequestedOneTimePasscode.json.golden @@ -9,14 +9,19 @@ "user_email": "bobby@coder.com", "user_name": "Bobby", "user_username": "bobby", - "actions": [], + "actions": [ + { + "label": "Reset password", + "url": "http://test.com/reset-password/change?otp=00000000-0000-0000-0000-000000000000\u0026email=bobby@coder.com" + } + ], "labels": { "one_time_passcode": "00000000-0000-0000-0000-000000000000" }, "data": null }, - "title": "Your One-Time Passcode for Coder.", - "title_markdown": "Your One-Time Passcode for Coder.", - "body": "Hi Bobby,\n\nA request to reset the password for your Coder account has been made. Your one-time passcode is:\n\n00000000-0000-0000-0000-000000000000\n\nIf you did not request to reset your password, you can ignore this message.", - "body_markdown": "Hi Bobby,\n\nA request to reset the password for your Coder account has been made. Your one-time passcode is:\n\n**00000000-0000-0000-0000-000000000000**\n\nIf you did not request to reset your password, you can ignore this message." + "title": "Reset your password for Coder", + "title_markdown": "Reset your password for Coder", + "body": "Hi Bobby,\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message.", + "body_markdown": "Hi Bobby,\n\nUse the link below to reset your password.\n\nIf you did not make this request, you can ignore this message." } \ No newline at end of file diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 103a3c50e7900..7d87d9c8c2104 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2167,6 +2167,18 @@ class ApiMethods { ); return res.data; }; + + requestOneTimePassword = async ( + req: TypesGen.RequestOneTimePasscodeRequest, + ) => { + await this.axios.post("/api/v2/users/otp/request", req); + }; + + changePasswordWithOTP = async ( + req: TypesGen.ChangePasswordWithOneTimePasscodeRequest, + ) => { + await this.axios.post("/api/v2/users/otp/change-password", req); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 427054b3fe5e2..833d88e6baeef 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -3,6 +3,7 @@ import type { AuthorizationRequest, GenerateAPIKeyResponse, GetUsersResponse, + RequestOneTimePasscodeRequest, UpdateUserAppearanceSettingsRequest, UpdateUserPasswordRequest, UpdateUserProfileRequest, @@ -253,3 +254,16 @@ export const updateAppearanceSettings = ( }, }; }; + +export const requestOneTimePassword = () => { + return { + mutationFn: (req: RequestOneTimePasscodeRequest) => + API.requestOneTimePassword(req), + }; +}; + +export const changePasswordWithOTP = () => { + return { + mutationFn: API.changePasswordWithOTP, + }; +}; diff --git a/site/src/components/CustomLogo/CustomLogo.tsx b/site/src/components/CustomLogo/CustomLogo.tsx new file mode 100644 index 0000000000000..e207e8fac27b9 --- /dev/null +++ b/site/src/components/CustomLogo/CustomLogo.tsx @@ -0,0 +1,33 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { CoderIcon } from "components/Icons/CoderIcon"; +import type { FC } from "react"; +import { getApplicationName, getLogoURL } from "utils/appearance"; + +/** + * Enterprise customers can set a custom logo for their Coder application. Use + * the custom logo wherever the Coder logo is used, if a custom one is provided. + */ +export const CustomLogo: FC<{ css?: Interpolation }> = (props) => { + const applicationName = getApplicationName(); + const logoURL = getLogoURL(); + + return logoURL ? ( + {applicationName} { + e.currentTarget.style.display = "none"; + }} + onLoad={(e) => { + e.currentTarget.style.display = "inline"; + }} + css={{ maxWidth: 200 }} + className="application-logo" + /> + ) : ( + + ); +}; diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index 8b9a5ec472554..9404722431583 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -1,11 +1,10 @@ import type { Interpolation, Theme } from "@emotion/react"; import Button from "@mui/material/Button"; import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated"; -import { CoderIcon } from "components/Icons/CoderIcon"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; import { Loader } from "components/Loader/Loader"; import { type FC, useState } from "react"; import { useLocation } from "react-router-dom"; -import { getApplicationName, getLogoURL } from "utils/appearance"; import { retrieveRedirect } from "utils/redirect"; import { SignInForm } from "./SignInForm"; import { TermsOfServiceLink } from "./TermsOfServiceLink"; @@ -32,29 +31,6 @@ export const LoginPageView: FC = ({ // This allows messages to be displayed at the top of the sign in form. // Helpful for any redirects that want to inform the user of something. const message = new URLSearchParams(location.search).get("message"); - const applicationName = getApplicationName(); - const logoURL = getLogoURL(); - const applicationLogo = logoURL ? ( - {applicationName} { - e.currentTarget.style.display = "none"; - }} - onLoad={(e) => { - e.currentTarget.style.display = "inline"; - }} - css={{ - maxWidth: "200px", - }} - className="application-logo" - /> - ) : ( - - ); - const [tosAccepted, setTosAccepted] = useState(false); const tosAcceptanceRequired = authMethods?.terms_of_service_url && !tosAccepted; @@ -62,7 +38,7 @@ export const LoginPageView: FC = ({ return (
- {applicationLogo} + {isLoading ? ( ) : tosAcceptanceRequired ? ( diff --git a/site/src/pages/LoginPage/PasswordSignInForm.tsx b/site/src/pages/LoginPage/PasswordSignInForm.tsx index d1e7ab9194f6f..e2ca4dc5bcfaa 100644 --- a/site/src/pages/LoginPage/PasswordSignInForm.tsx +++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx @@ -1,8 +1,10 @@ import LoadingButton from "@mui/lab/LoadingButton"; +import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { Stack } from "components/Stack/Stack"; import { useFormik } from "formik"; import type { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; import * as Yup from "yup"; import { Language } from "./SignInForm"; @@ -65,6 +67,17 @@ export const PasswordSignInForm: FC = ({ > {Language.passwordSignIn} + + Forgot password? + ); diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx new file mode 100644 index 0000000000000..d59ead3a59579 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import { mockApiError } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import ChangePasswordPage from "./ChangePasswordPage"; + +const meta: Meta = { + title: "pages/ResetPasswordPage/ChangePasswordPage", + component: ChangePasswordPage, + args: { redirect: false }, + decorators: [withGlobalSnackbar], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Success: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "changePasswordWithOTP").mockResolvedValueOnce(); + const canvas = within(canvasElement); + const user = userEvent.setup(); + const newPasswordInput = await canvas.findByLabelText("Password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = + await canvas.findByLabelText("Confirm password *"); + await user.type(confirmPasswordInput, "password"); + await user.click(canvas.getByRole("button", { name: /reset password/i })); + await canvas.findByText("Password reset successfully"); + }, +}; + +export const WrongConfirmationPassword: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce( + mockApiError({ + message: "New password should be different from the old password", + }), + ); + const canvas = within(canvasElement); + const user = userEvent.setup(); + const newPasswordInput = await canvas.findByLabelText("Password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = + await canvas.findByLabelText("Confirm password *"); + await user.type(confirmPasswordInput, "different-password"); + await user.click(canvas.getByRole("button", { name: /reset password/i })); + await canvas.findByText("Passwords must match"); + }, +}; + +export const ServerError: Story = { + play: async ({ canvasElement }) => { + const serverError = + "New password should be different from the old password"; + spyOn(API, "changePasswordWithOTP").mockRejectedValueOnce( + mockApiError({ + message: serverError, + }), + ); + const canvas = within(canvasElement); + const user = userEvent.setup(); + const newPasswordInput = await canvas.findByLabelText("Password *"); + await user.type(newPasswordInput, "password"); + const confirmPasswordInput = + await canvas.findByLabelText("Confirm password *"); + await user.type(confirmPasswordInput, "password"); + await user.click(canvas.getByRole("button", { name: /reset password/i })); + await canvas.findByText(serverError); + }, +}; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx new file mode 100644 index 0000000000000..077bc39da82d4 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -0,0 +1,174 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import { changePasswordWithOTP } from "api/queries/users"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Stack } from "components/Stack/Stack"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation } from "react-query"; +import { + Link as RouterLink, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { getApplicationName } from "utils/appearance"; +import { getFormHelpers } from "utils/formUtils"; +import * as yup from "yup"; + +const validationSchema = yup.object({ + password: yup.string().required("Password is required"), + confirmPassword: yup + .string() + .required("Confirm password is required") + .test("passwords-match", "Passwords must match", function (value) { + return this.parent.password === value; + }), +}); + +type ChangePasswordChangeProps = { + // This is used to prevent redirection when testing the page in Storybook and + // capturing Chromatic snapshots. + redirect?: boolean; +}; + +const ChangePasswordPage: FC = ({ redirect }) => { + const navigate = useNavigate(); + const applicationName = getApplicationName(); + const changePasswordMutation = useMutation(changePasswordWithOTP()); + const [searchParams] = useSearchParams(); + + const form = useFormik({ + initialValues: { + password: "", + confirmPassword: "", + }, + validateOnBlur: false, + validationSchema, + onSubmit: async (values) => { + const email = searchParams.get("email") ?? ""; + const otp = searchParams.get("otp") ?? ""; + + await changePasswordMutation.mutateAsync({ + email, + one_time_passcode: otp, + password: values.password, + }); + displaySuccess("Password reset successfully"); + if (redirect) { + navigate("/login"); + } + }, + }); + const getFieldHelpers = getFormHelpers(form); + + return ( + <> + + Reset Password - {applicationName} + + +
+
+ +

+ Choose a new password +

+ {changePasswordMutation.error ? ( + + ) : null} +
+
+ + + + + + + + Reset password + + + + +
+
+
+
+ + ); +}; + +const styles = { + logo: { + marginBottom: 40, + }, + root: { + padding: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + minHeight: "100%", + textAlign: "center", + }, + container: { + width: "100%", + maxWidth: 320, + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + icon: { + fontSize: 64, + }, + footer: (theme) => ({ + fontSize: 12, + color: theme.palette.text.secondary, + marginTop: 24, + }), +} satisfies Record>; + +export default ChangePasswordPage; diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx new file mode 100644 index 0000000000000..5f75f607ab9d3 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import { mockApiError } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import RequestOTPPage from "./RequestOTPPage"; + +const meta: Meta = { + title: "pages/ResetPasswordPage/RequestOTPPage", + component: RequestOTPPage, + decorators: [withGlobalSnackbar], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Success: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "requestOneTimePassword").mockResolvedValueOnce(); + const canvas = within(canvasElement); + const user = userEvent.setup(); + const emailInput = await canvas.findByLabelText(/email/i); + await user.type(emailInput, "admin@coder.com"); + await user.click(canvas.getByRole("button", { name: /reset password/i })); + }, +}; + +export const ServerError: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "requestOneTimePassword").mockRejectedValueOnce( + mockApiError({ + message: "Error requesting password change", + }), + ); + const canvas = within(canvasElement); + const user = userEvent.setup(); + const emailInput = await canvas.findByLabelText(/email/i); + await user.type(emailInput, "admin@coder.com"); + await user.click(canvas.getByRole("button", { name: /reset password/i })); + }, +}; diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx new file mode 100644 index 0000000000000..0a097971b6626 --- /dev/null +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.tsx @@ -0,0 +1,193 @@ +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import { getErrorMessage } from "api/errors"; +import { requestOneTimePassword } from "api/queries/users"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { CustomLogo } from "components/CustomLogo/CustomLogo"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Stack } from "components/Stack/Stack"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation } from "react-query"; +import { Link as RouterLink } from "react-router-dom"; +import { getApplicationName } from "utils/appearance"; + +const RequestOTPPage: FC = () => { + const applicationName = getApplicationName(); + const requestOTPMutation = useMutation(requestOneTimePassword()); + + return ( + <> + + Reset Password - {applicationName} + + +
+ + {requestOTPMutation.isSuccess ? ( + + ) : ( + { + requestOTPMutation.mutate({ email }); + }} + /> + )} +
+ + ); +}; + +type RequestOTPProps = { + error: unknown; + onRequest: (email: string) => void; + isRequesting: boolean; +}; + +const RequestOTP: FC = ({ + error, + onRequest, + isRequesting, +}) => { + return ( +
+
+

+ Enter your email to reset the password +

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

+ If the account{" "} + + {email} + {" "} + exists, you will get an email with instructions on resetting your + password. +

+ +

+ Contact your deployment administrator if you encounter issues. +

+ + +
+
+ ); +}; + +const styles = { + logo: { + marginBottom: 40, + }, + root: { + padding: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + minHeight: "100%", + textAlign: "center", + }, + container: { + width: "100%", + maxWidth: 320, + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + icon: { + fontSize: 64, + }, + footer: (theme) => ({ + fontSize: 12, + color: theme.palette.text.secondary, + marginTop: 24, + }), +} satisfies Record>; + +export default RequestOTPPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index 526be40a2b168..2531c823b9f48 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -287,6 +287,12 @@ const DeploymentNotificationsPage = lazy( "./pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage" ), ); +const RequestOTPPage = lazy( + () => import("./pages/ResetPasswordPage/RequestOTPPage"), +); +const ChangePasswordPage = lazy( + () => import("./pages/ResetPasswordPage/ChangePasswordPage"), +); const RoutesWithSuspense = () => { return ( @@ -348,6 +354,10 @@ export const router = createBrowserRouter( }> } /> } /> + + } /> + } /> + {/* Dashboard routes */} }>