From 1093c67d61f87abef5c961cd3c59ecf4906b55ad Mon Sep 17 00:00:00 2001 From: Alexander Saiannyi Date: Thu, 7 Mar 2024 21:38:12 +0100 Subject: [PATCH] feat(core): add components for reset password --- .changeset/itchy-deers-argue.md | 6 + .../_actions/submit-reset-password-form.ts | 44 +++++ .../login/_components/reset-password-form.tsx | 170 ++++++++++++++++++ .../app/[locale]/(default)/login/page.tsx | 15 ++ .../client/mutations/submit-reset-password.ts | 4 +- apps/core/messages/en.json | 11 ++ 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 .changeset/itchy-deers-argue.md create mode 100644 apps/core/app/[locale]/(default)/login/_actions/submit-reset-password-form.ts create mode 100644 apps/core/app/[locale]/(default)/login/_components/reset-password-form.tsx diff --git a/.changeset/itchy-deers-argue.md b/.changeset/itchy-deers-argue.md new file mode 100644 index 000000000..4fdb6d2d5 --- /dev/null +++ b/.changeset/itchy-deers-argue.md @@ -0,0 +1,6 @@ +--- +"@bigcommerce/catalyst-core": minor +"@bigcommerce/components": patch +--- +Add reset password functionality +Update props for message field diff --git a/apps/core/app/[locale]/(default)/login/_actions/submit-reset-password-form.ts b/apps/core/app/[locale]/(default)/login/_actions/submit-reset-password-form.ts new file mode 100644 index 000000000..6ae1419de --- /dev/null +++ b/apps/core/app/[locale]/(default)/login/_actions/submit-reset-password-form.ts @@ -0,0 +1,44 @@ +'use server'; + +import { z } from 'zod'; + +import { ResetPasswordSchema, submitResetPassword } from '~/client/mutations/submit-reset-password'; + +interface SubmitResetPasswordForm { + formData: FormData; + path: string; + reCaptchaToken: string; +} + +export const submitResetPasswordForm = async ({ + formData, + path, + reCaptchaToken, +}: SubmitResetPasswordForm) => { + try { + const parsedData = ResetPasswordSchema.parse({ + email: formData.get('email'), + }); + + const response = await submitResetPassword({ + email: parsedData.email, + path, + reCaptchaToken, + }); + + if (response.customer.requestResetPassword.errors.length === 0) { + return { status: 'success', data: parsedData }; + } + + return { + status: 'error', + error: response.customer.requestResetPassword.errors.map((error) => error.message).join('\n'), + }; + } catch (error: unknown) { + if (error instanceof Error || error instanceof z.ZodError) { + return { status: 'error', error: error.message }; + } + + return { status: 'error', error: 'Unknown error' }; + } +}; diff --git a/apps/core/app/[locale]/(default)/login/_components/reset-password-form.tsx b/apps/core/app/[locale]/(default)/login/_components/reset-password-form.tsx new file mode 100644 index 000000000..0991d4dbc --- /dev/null +++ b/apps/core/app/[locale]/(default)/login/_components/reset-password-form.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { Button } from '@bigcommerce/components/button'; +import { + Field, + FieldControl, + FieldLabel, + FieldMessage, + Form, + FormSubmit, +} from '@bigcommerce/components/form'; +import { Input } from '@bigcommerce/components/input'; +import { Message } from '@bigcommerce/components/message'; +import { Loader2 as Spinner } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { ChangeEvent, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import ReCaptcha from 'react-google-recaptcha'; + +import { submitResetPasswordForm } from '../_actions/submit-reset-password-form'; + +interface Props { + reCaptchaSettings?: { + isEnabledOnStorefront: boolean; + siteKey: string; + }; +} + +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +const SubmitButton = () => { + const { pending } = useFormStatus(); + const t = useTranslations('Account.SubmitResetPassword'); + + return ( + + ); +}; + +export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => { + const form = useRef(null); + const [formStatus, setFormStatus] = useState(null); + const [isEmailValid, setIsEmailValid] = useState(true); + + const t = useTranslations('Account.ResetPassword'); + + const reCaptchaRef = useRef(null); + const [reCaptchaToken, setReCaptchaToken] = useState(''); + const [isReCaptchaValid, setReCaptchaValid] = useState(true); + + const onReCatpchaChange = (token: string | null) => { + if (!token) { + return setReCaptchaValid(false); + } + + setReCaptchaToken(token); + setReCaptchaValid(true); + }; + + const handleEmailValidation = (e: ChangeEvent) => { + const validationStatus = e.target.validity.valueMissing; + + return setIsEmailValid(!validationStatus); + }; + + const onSubmit = async (formData: FormData) => { + if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { + return setReCaptchaValid(false); + } + + setReCaptchaValid(true); + + const submit = await submitResetPasswordForm({ + formData, + reCaptchaToken, + path: '/login?action=change_password', + }); + + if (submit.status === 'success') { + form.current?.reset(); + + const customerEmail = formData.get('email'); + + setFormStatus({ + status: 'success', + message: t('successMessage', { email: customerEmail?.toString() }), + }); + } + + if (submit.status === 'error') { + setFormStatus({ status: 'error', message: submit.error ?? '' }); + } + + reCaptchaRef.current?.reset(); + }; + + return ( + <> + {formStatus && ( + +

{formStatus.message}

+
+ )} + +

{t('description')}

+ +
+ + {t('emailLabel')} + + + + + {t('emailValidationMessage')} + + + + {reCaptchaSettings?.isEnabledOnStorefront && ( + + + {!isReCaptchaValid && ( + + {t('recaptchaText')} + + )} + + )} + + + + +
+ + ); +}; diff --git a/apps/core/app/[locale]/(default)/login/page.tsx b/apps/core/app/[locale]/(default)/login/page.tsx index 9c06fffe0..6d260fc70 100644 --- a/apps/core/app/[locale]/(default)/login/page.tsx +++ b/apps/core/app/[locale]/(default)/login/page.tsx @@ -2,11 +2,13 @@ import { Button } from '@bigcommerce/components/button'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages, getTranslations } from 'next-intl/server'; +import { getReCaptchaSettings } from '~/client/queries/get-recaptcha-settings'; import { Link } from '~/components/link'; import { LocaleType } from '~/i18n'; import { ChangePasswordForm } from './_components/change-password-form'; import { LoginForm } from './_components/login-form'; +import { ResetPasswordForm } from './_components/reset-password-form'; export const metadata = { title: 'Login', @@ -43,6 +45,19 @@ export default async function Login({ params: { locale }, searchParams }: Props) ); } + if (action === 'reset_password') { + const reCaptchaSettings = await getReCaptchaSettings(); + + return ( +
+

{t('resetPasswordHeading')}

+ + + +
+ ); + } + return (

{t('heading')}

diff --git a/apps/core/client/mutations/submit-reset-password.ts b/apps/core/client/mutations/submit-reset-password.ts index f41da70c0..e22f6fea5 100644 --- a/apps/core/client/mutations/submit-reset-password.ts +++ b/apps/core/client/mutations/submit-reset-password.ts @@ -8,6 +8,7 @@ export const ResetPasswordSchema = z.object({ }); type SubmitResetPassword = z.infer & { + path: string; reCaptchaToken?: string; }; @@ -27,10 +28,11 @@ const SUBMIT_RESET_PASSWORD_MUTATION = graphql(` } `); -export const submitResetPassword = async ({ email, reCaptchaToken }: SubmitResetPassword) => { +export const submitResetPassword = async ({ email, path, reCaptchaToken }: SubmitResetPassword) => { const variables = { input: { email, + path, }, ...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }), }; diff --git a/apps/core/messages/en.json b/apps/core/messages/en.json index 03dd1d2cc..726ed6856 100644 --- a/apps/core/messages/en.json +++ b/apps/core/messages/en.json @@ -191,6 +191,17 @@ "SubmitChangePassword": { "spinnerText": "Submitting...", "submitText": "Change password" + }, + "ResetPassword": { + "description": "Enter the email associated with your account below. We'll send you instructions to reset your password.", + "emailLabel": "Email", + "emailValidationMessage": "Enter a valid email such as name@domain.com", + "successMessage": "Your password reset email is on its way to {email}. If you don't see it, check your spam folder.", + "recaptchaText": "Pass ReCAPTCHA check" + }, + "SubmitResetPassword": { + "spinnerText": "Submitting...", + "submitText": "Reset password" } }, "NotFound": {