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..f4a977c7a --- /dev/null +++ b/apps/core/app/[locale]/(default)/login/_actions/submit-reset-password-form.ts @@ -0,0 +1,41 @@ +'use server'; + +import { z } from 'zod'; + +import { ResetPasswordSchema, submitResetPassword } from '~/client/mutations/submit-reset-password'; + +interface SubmitRessetPasswordForm { + formData: FormData; + reCaptchaToken: string; +} + +export const submitResetPasswordForm = async ({ + formData, + reCaptchaToken, +}: SubmitRessetPasswordForm) => { + try { + const parsedData = ResetPasswordSchema.parse({ + email: formData.get('email'), + }); + + const response = await submitResetPassword({ + email: parsedData.email, + reCaptchaToken, + }); + + if (response.customer.requestResetPassword.errors.length === 0) { + return { status: 'success', data: parsedData }; + } + + return { + status: 'failed', + error: response.customer.requestResetPassword.errors.map((error) => error.message).join('\n'), + }; + } catch (error: unknown) { + if (error instanceof Error || error instanceof z.ZodError) { + return { status: 'failed', error: error.message }; + } + + return { status: 'failed', 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..1c098711c --- /dev/null +++ b/apps/core/app/[locale]/(default)/login/_components/reset-password-form.tsx @@ -0,0 +1,166 @@ +'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 }); + + if (submit.status === 'success') { + form.current?.reset(); + + const customerEmail = formData.get('email'); + + setFormStatus({ + status: 'success', + message: t('successMessage', { email: customerEmail?.toString() }), + }); + } + + if (submit.status === 'failed') { + 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 977a33203..389678795 100644 --- a/apps/core/app/[locale]/(default)/login/page.tsx +++ b/apps/core/app/[locale]/(default)/login/page.tsx @@ -1,10 +1,13 @@ import { Button } from '@bigcommerce/components/button'; -import { NextIntlClientProvider, useMessages, useTranslations } from 'next-intl'; +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 { LoginForm } from './_components/login-form'; +import { ResetPasswordForm } from './_components/reset-password-form'; export const metadata = { title: 'Login', @@ -14,17 +17,38 @@ interface Props { params: { locale: LocaleType; }; + searchParams: { + [key: string]: string | string[] | undefined; + action?: 'create_account' | 'reset_password' | 'change_password'; + c?: string; + t?: string; + }; } -export default function Login({ params: { locale } }: Props) { - const messages = useMessages(); - const t = useTranslations('Account.Login'); +export default async function Login({ params: { locale }, searchParams }: Props) { + const messages = await getMessages({ locale }); + const Account = messages.Account ?? {}; + const t = await getTranslations({ locale, namespace: 'Account.Login' }); + const action = searchParams.action; + + if (action === 'reset_password') { + const reCaptchaSettings = await getReCaptchaSettings(); + + return ( +
+

{t('resetPasswordHeading')}

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

{t('heading')}

+

{t('heading')}

- +
diff --git a/apps/core/client/mutations/submit-change-password.ts b/apps/core/client/mutations/submit-change-password.ts index 9906f2398..7936cd9c9 100644 --- a/apps/core/client/mutations/submit-change-password.ts +++ b/apps/core/client/mutations/submit-change-password.ts @@ -4,8 +4,8 @@ import { client } from '..'; import { graphql } from '../generated'; export const ChangePasswordSchema = z.object({ - newPassword: z.string(), - confirmPassword: z.string(), + newPassword: z.string().min(7), + confirmPassword: z.string().min(7), }); interface SubmitChangePassword { diff --git a/apps/core/messages/en.json b/apps/core/messages/en.json index 3928b61dd..d5cb0d8d6 100644 --- a/apps/core/messages/en.json +++ b/apps/core/messages/en.json @@ -159,6 +159,8 @@ }, "Login": { "heading": "Log In", + "resetPasswordHeading": "Reset password", + "changePasswordHeading": "Change password", "Form": { "errorMessage": "Your email address or password is incorrect. Try signing in again or reset your password", "emailLabel": "Email", @@ -179,6 +181,17 @@ "wishlists": "Save items to your Wish List", "createLink": "Create Account " } + }, + "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": { diff --git a/packages/components/src/components/form/form.tsx b/packages/components/src/components/form/form.tsx index 999eb953d..95b37228c 100644 --- a/packages/components/src/components/form/form.tsx +++ b/packages/components/src/components/form/form.tsx @@ -45,18 +45,14 @@ const Field = forwardRef< Field.displayName = 'Field'; -interface FieldMessageProps - extends Omit, 'match'> { - match?: ValidationPattern; -} - -const FieldMessage = forwardRef, FieldMessageProps>( - ({ className, children, ...props }, ref) => ( - - {children} - - ), -); +const FieldMessage = forwardRef< + ElementRef, + ComponentPropsWithRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); FieldMessage.displayName = 'FieldMessage';