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 26, 2024
1 parent 980e481 commit 1093c67
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 1 deletion.
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: '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' };
}
};
Original file line number Diff line number Diff line change
@@ -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 (
<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?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 && (
<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="absolute inset-x-0 bottom-0 inline-flex w-full text-xs font-normal text-red-200">
{t('recaptchaText')}
</span>
)}
</Field>
)}

<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
</Form>
</>
);
};
15 changes: 15 additions & 0 deletions apps/core/app/[locale]/(default)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -43,6 +45,19 @@ export default async function Login({ params: { locale }, searchParams }: Props)
);
}

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 text-4xl font-black lg:text-5xl">{t('heading')}</h2>
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,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 } }),
};
Expand Down
11 changes: 11 additions & 0 deletions apps/core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down

0 comments on commit 1093c67

Please sign in to comment.