Skip to content

Commit

Permalink
feat(core): add components for reset password
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-alexsaiannyi committed Mar 18, 2024
1 parent 801bead commit e9ed37d
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 21 deletions.
6 changes: 6 additions & 0 deletions .changeset/itchy-deers-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bigcommerce/catalyst-core": minor
"@bigcommerce/components": patch
---
Add reset password functionality
Update props for message field
Original file line number Diff line number Diff line change
@@ -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: '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' };
}
};
Original file line number Diff line number Diff line change
@@ -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 (
<Button
className="relative w-fit items-center px-8 py-2"
data-button
disabled={pending}
variant="primary"
>
<>
{pending && (
<>
<span className="absolute z-10 flex h-full w-full items-center justify-center bg-gray-400">
<Spinner aria-hidden="true" className="animate-spin" />
</span>
<span className="sr-only">{t('spinnerText')}</span>
</>
)}
<span aria-hidden={pending}>{t('submitText')}</span>
</>
</Button>
);
};

export const ResetPasswordForm = ({ reCaptchaSettings }: Props) => {
const form = useRef<HTMLFormElement>(null);
const [formStatus, setFormStatus] = useState<FormStatus | null>(null);
const [isEmailValid, setIsEmailValid] = useState(true);

const t = useTranslations('Account.ResetPassword');

const reCaptchaRef = useRef<ReCaptcha>(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<HTMLInputElement>) => {
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' });

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 && (
<Message className="mb-8 w-full" variant={formStatus.status}>
<p>{formStatus.message}</p>
</Message>
)}

<p className="mb-4 text-base">{t('description')}</p>

<Form action={onSubmit} className="mb-14 flex flex-col gap-4 md:py-4 lg:p-0" ref={form}>
<Field className="relative space-y-2 pb-7" name="email">
<FieldLabel htmlFor="email">{t('emailLabel')}</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="email"
id="email"
onChange={handleEmailValidation}
onInvalid={handleEmailValidation}
required
type="email"
variant={!isEmailValid ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match="valueMissing"
>
{t('emailValidationMessage')}
</FieldMessage>
</Field>

{reCaptchaSettings?.isEnabledOnStorefront && (
<Field className="relative col-span-full max-w-full space-y-2 pb-7" name="ReCAPTCHA">
<ReCaptcha
onChange={onReCatpchaChange}
ref={reCaptchaRef}
sitekey={reCaptchaSettings.siteKey}
/>
{!isReCaptchaValid && (
<span className="text-red-200 absolute inset-x-0 bottom-0 inline-flex w-full text-xs font-normal">
{t('recaptchaText')}
</span>
)}
</Field>
)}

<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
</Form>
</>
);
};
36 changes: 30 additions & 6 deletions apps/core/app/[locale]/(default)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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 (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="mb-8 text-4xl font-black lg:text-5xl">{t('resetPasswordHeading')}</h2>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<ResetPasswordForm reCaptchaSettings={reCaptchaSettings} />
</NextIntlClientProvider>
</div>
);
}

return (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="text-h2 mb-8">{t('heading')}</h2>
<h2 className="text-h2 mb-8 text-4xl font-black lg:text-5xl">{t('heading')}</h2>
<div className="mb-12 grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<NextIntlClientProvider locale={locale} messages={{ Account: messages.Account ?? {} }}>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<LoginForm />
</NextIntlClientProvider>
<div className="flex flex-col gap-4 bg-gray-100 p-8">
Expand Down
4 changes: 2 additions & 2 deletions apps/core/client/mutations/submit-change-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion apps/core/client/mutations/submit-reset-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const ResetPasswordSchema = z.object({
});

type SubmitResetPassword = z.infer<typeof ResetPasswordSchema> & {
path: string;
reCaptchaToken?: string;
};

Expand All @@ -27,12 +28,13 @@ const SUBMIT_RESET_PASSWORD_MUTATION = /* GraphQL */ `
}
`;

export const submitResetPassword = async ({ email, reCaptchaToken }: SubmitResetPassword) => {
export const submitResetPassword = async ({ email, path, reCaptchaToken }: SubmitResetPassword) => {
const mutation = graphql(SUBMIT_RESET_PASSWORD_MUTATION);

const variables = {
input: {
email,
path,
},
...(reCaptchaToken && { reCaptchaV2: { token: reCaptchaToken } }),
};
Expand Down
13 changes: 13 additions & 0 deletions apps/core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
20 changes: 8 additions & 12 deletions packages/components/src/components/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,14 @@ const Field = forwardRef<

Field.displayName = 'Field';

interface FieldMessageProps
extends Omit<ComponentPropsWithRef<typeof FormPrimitive.Message>, 'match'> {
match?: ValidationPattern;
}

const FieldMessage = forwardRef<ElementRef<typeof FormPrimitive.Message>, FieldMessageProps>(
({ className, children, ...props }, ref) => (
<FormPrimitive.Message className={cn(className)} ref={ref} {...props}>
{children}
</FormPrimitive.Message>
),
);
const FieldMessage = forwardRef<
ElementRef<typeof FormPrimitive.Message>,
ComponentPropsWithRef<typeof FormPrimitive.Message>
>(({ className, children, ...props }, ref) => (
<FormPrimitive.Message className={cn(className)} ref={ref} {...props}>
{children}
</FormPrimitive.Message>
));

FieldMessage.displayName = 'FieldMessage';

Expand Down

0 comments on commit e9ed37d

Please sign in to comment.