Skip to content

Commit

Permalink
feat(core): add components for change password
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-alexsaiannyi committed Mar 19, 2024
1 parent 9c5bb8c commit a15fcb1
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 20 deletions.
5 changes: 5 additions & 0 deletions .changeset/stale-tigers-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

add components for change password
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use server';

import { z } from 'zod';

import {
ChangePasswordSchema,
submitChangePassword,
} from '~/client/mutations/submit-change-password';

interface SubmitChangePasswordForm {
formData: FormData;
customerId: number;
customerToken: string;
}

export const submitChangePasswordForm = async ({
formData,
customerId,
customerToken,
}: SubmitChangePasswordForm) => {
try {
const parsedData = ChangePasswordSchema.parse({
newPassword: formData.get('new-password'),
confirmPassword: formData.get('confirm-password'),
});

const response = await submitChangePassword({
newPassword: parsedData.newPassword,
token: customerToken,
customerEntityId: customerId,
});

if (response.customer.resetPassword.errors.length === 0) {
return { status: 'success', data: parsedData };
}

return {
status: 'failed',
error: response.customer.resetPassword.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,165 @@
'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 { useRouter } from '~/navigation';

import { submitChangePasswordForm } from '../_actions/submit-change-password-form';

interface Props {
customerId: number;
customerToken: string;
}

interface FormStatus {
status: 'success' | 'error';
message: string;
}

const SubmitButton = () => {
const { pending } = useFormStatus();
const t = useTranslations('Account.SubmitChangePassword');

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 ChangePasswordForm = ({ customerId, customerToken }: Props) => {
const form = useRef<HTMLFormElement>(null);
const router = useRouter();
const [formStatus, setFormStatus] = useState<FormStatus | null>(null);
const [newPassword, setNewPasssword] = useState('');
const [isNewPasswordValid, setIsNewPasswordValid] = useState(true);
const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true);

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

const handleNewPasswordValidation = (e: ChangeEvent<HTMLInputElement>) => {
const validationStatus = e.target.validity.patternMismatch;

setNewPasssword(e.target.value);

return setIsNewPasswordValid(!validationStatus);
};

const handleConfirmPasswordValidation = (e: ChangeEvent<HTMLInputElement>) => {
const confirmPassword = e.target.value;

return setIsConfirmPasswordValid(confirmPassword === newPassword);
};

const onSubmit = async (formData: FormData) => {
const submit = await submitChangePasswordForm({ formData, customerId, customerToken });

if (submit.status === 'success') {
form.current?.reset();
setFormStatus({
status: 'success',
message: t('successMessage'),
});

setTimeout(() => router.push('/login'), 2000);
}

if (submit.status === 'failed') {
setFormStatus({ status: 'error', message: submit.error ?? '' });
}
};

return (
<>
{formStatus && (
<Message className="mb-8 w-full text-gray-500" 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="new-password">
<FieldLabel htmlFor="new-password" isRequired={true}>
{t('newPasswordLabel')}
</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="none"
id="new-password"
onChange={handleNewPasswordValidation}
onInvalid={handleNewPasswordValidation}
pattern="^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{7,}$"
required
type="password"
variant={!isNewPasswordValid ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match="patternMismatch"
>
{t('newPasswordValidationMessage')}
</FieldMessage>
</Field>

<Field className="relative space-y-2 pb-7" name="confirm-password">
<FieldLabel htmlFor="confirm-password" isRequired={true}>
{t('confirmPasswordLabel')}
</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="none"
id="confirm-password"
onChange={handleConfirmPasswordValidation}
onInvalid={handleConfirmPasswordValidation}
required
type="password"
variant={!isConfirmPasswordValid ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match={(value: string) => value !== newPassword}
>
{t('confirmPasswordValidationMessage')}
</FieldMessage>
</Field>

<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
</Form>
</>
);
};
35 changes: 29 additions & 6 deletions apps/core/app/[locale]/(default)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { Link } from '~/components/link';
import { LocaleType } from '~/i18n';

import { ChangePasswordForm } from './_components/change-password-form';
import { LoginForm } from './_components/login-form';

export const metadata = {
Expand All @@ -14,17 +16,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;
const customerId = searchParams.c;
const customerToken = searchParams.t;

if (action === 'change_password' && customerId && customerToken) {
return (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="mb-8 text-4xl font-black lg:text-5xl">{t('changePasswordHeading')}</h2>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<ChangePasswordForm customerId={Number(customerId)} customerToken={customerToken} />
</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 '../graphql';

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
14 changes: 14 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,18 @@
"wishlists": "Save items to your Wish List",
"createLink": "Create Account "
}
},
"ChangePassword": {
"description": "Passwords must be at least 7 characters and contain both alphabetic and numeric characters.",
"newPasswordLabel": "New password",
"newPasswordValidationMessage": "Must be be at least 7 characters and contain both alphabetic and numeric characters",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordValidationMessage": "Entered passwords are mismatched. Please try again.",
"successMessage": "Password has been updated successfully!"
},
"SubmitChangePassword": {
"spinnerText": "Submitting...",
"submitText": "Change 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 a15fcb1

Please sign in to comment.