Skip to content

Commit

Permalink
feat(core): add change password for logged-in customer
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-alexsaiannyi committed May 8, 2024
1 parent 6afd7a2 commit b09b19c
Show file tree
Hide file tree
Showing 10 changed files with 430 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/happy-eggs-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

add change password for logged-in customer
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use server';

import { z } from 'zod';

import { CustomerChangePasswordSchema } from '~/client/mutations/submit-change-password';
import { submitCustomerChangePassword } from '~/client/mutations/submit-customer-change-password';

export interface State {
status: 'idle' | 'error' | 'success';
message?: string;
}

export const submitCustomerChangePasswordForm = async (
_previousState: unknown,
formData: FormData,
) => {
try {
const parsedData = CustomerChangePasswordSchema.parse({
newPassword: formData.get('new-password'),
currentPassword: formData.get('current-password'),
confirmPassword: formData.get('confirm-password'),
});

const response = await submitCustomerChangePassword({
newPassword: parsedData.newPassword,
currentPassword: parsedData.currentPassword,
});

if (response.errors.length === 0) {
return { status: 'success', message: 'Password has been updated successfully.' };
}

return {
status: 'error',
message: response.errors.map((error) => error.message).join('\n'),
};
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return {
status: 'error',
message: error.issues
.map(({ path, message }) => `${path.toString()}: ${message}.`)
.join('\n'),
};
}

if (error instanceof Error) {
return {
status: 'error',
message: error.message,
};
}

return { status: 'error', message: 'Unknown error.' };
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { getLocale, getTranslations } from 'next-intl/server';
import { getCustomerAddresses } from '~/client/queries/get-customer-addresses';

import { Pagination } from '../../../(faceted)/_components/pagination';
import { TabHeading } from '../_components/tab-heading';
import { TabType } from '../layout';
import { tabHeading } from '../page';

import { AddressesList } from './addresses-list';

Expand All @@ -23,7 +23,7 @@ export const AddressesContent = async ({ addresses, pageInfo, title }: Props) =>

return (
<>
{tabHeading(title, locale)}
<TabHeading heading={title} locale={locale} />
<AddressesList customerAddressBook={addresses} />
<Pagination
endCursor={endCursor}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
'use client';

import { Loader2 as Spinner } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { z } from 'zod';

import { CustomerChangePasswordSchema } from '~/client/mutations/submit-change-password';
import { logout } from '~/components/header/_actions/logout';
import { Link } from '~/components/link';
import { Button } from '~/components/ui/button';
import {
Field,
FieldControl,
FieldLabel,
FieldMessage,
Form,
FormSubmit,
} from '~/components/ui/form';
import { Input } from '~/components/ui/input';
import { Message } from '~/components/ui/message';
import { useRouter } from '~/navigation';

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

type Passwords = z.infer<typeof CustomerChangePasswordSchema>;

const validateAgainstConfirmPassword = ({
newPassword,
confirmPassword,
}: {
newPassword: Passwords['newPassword'];
confirmPassword: Passwords['confirmPassword'];
}): boolean => newPassword === confirmPassword;

const validateAgainstCurrentPassword = ({
newPassword,
currentPassword,
}: {
newPassword: Passwords['newPassword'];
currentPassword: Passwords['currentPassword'];
}): boolean => newPassword !== currentPassword;

export const validatePasswords = (
validationField: 'new-password' | 'confirm-password',
formData?: FormData,
) => {
if (!formData) {
return false;
}

if (validationField === 'new-password') {
return CustomerChangePasswordSchema.omit({ confirmPassword: true })
.refine(validateAgainstCurrentPassword)
.safeParse({
currentPassword: formData.get('current-password'),
newPassword: formData.get('new-password'),
}).success;
}

return CustomerChangePasswordSchema.refine(validateAgainstConfirmPassword).safeParse({
currentPassword: formData.get('current-password'),
newPassword: formData.get('new-password'),
confirmPassword: formData.get('confirm-password'),
}).success;
};

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

return (
<Button
className="relative w-full items-center px-8 py-2 md:w-fit"
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 = () => {
const router = useRouter();
const form = useRef<HTMLFormElement>(null);
const t = useTranslations('Account.ChangePassword');
const [state, formAction] = useFormState(submitCustomerChangePasswordForm, {
status: 'idle',
message: '',
});

const [isCurrentPasswordValid, setIsCurrentPasswordValid] = useState(true);
const [isNewPasswordValid, setIsNewPasswordValid] = useState(true);
const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true);

useEffect(() => {
if (state.status === 'success') {
setTimeout(() => {
void logout();
router.push('/login');
}, 2000);
}
}, [state, router]);

let messageText = '';

if (state.status === 'error') {
messageText = state.message;
}

if (state.status === 'success') {
messageText = t('successMessage');
}

const handleCurrentPasswordChange = (e: ChangeEvent<HTMLInputElement>) =>
setIsCurrentPasswordValid(!e.target.validity.valueMissing);
const handleNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
let formData;

if (e.target.form) {
formData = new FormData(e.target.form);
}

const confirmPassword = formData?.get('confirm-password');
const isValid = confirmPassword
? validatePasswords('new-password', formData) &&
validatePasswords('confirm-password', formData)
: validatePasswords('new-password', formData);

setIsNewPasswordValid(isValid);
};
const handleConfirmPasswordValidation = (e: ChangeEvent<HTMLInputElement>) => {
let formData;

if (e.target.form) {
formData = new FormData(e.target.form);
}

const isValid = validatePasswords('confirm-password', formData);

setIsConfirmPasswordValid(isValid);
};

return (
<>
{(state.status === 'error' || state.status === 'success') && (
<Message className="mb-8 w-full text-gray-500" variant={state.status}>
<p>{messageText}</p>
</Message>
)}

<Form action={formAction} 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="current-password">
<FieldLabel htmlFor="current-password" isRequired={true}>
{t('currentPasswordLabel')}
</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="none"
id="current-password"
onChange={handleCurrentPasswordChange}
onInvalid={handleCurrentPasswordChange}
required
type="password"
variant={!isCurrentPasswordValid ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-error"
match="valueMissing"
>
{t('notEmptyMessage')}
</FieldMessage>
</Field>
<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={handleNewPasswordChange}
onInvalid={handleNewPasswordChange}
required
type="password"
variant={!isNewPasswordValid ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-error"
match="valueMissing"
>
{t('notEmptyMessage')}
</FieldMessage>
<FieldMessage
className="absolute inset-x-0 inline-flex w-full text-sm text-error md:bottom-0"
match={(newPasswordValue: string, formData: FormData) => {
const currentPasswordValue = formData.get('current-password');
const confirmPassword = formData.get('confirm-password');
let isMatched;

if (confirmPassword) {
isMatched =
newPasswordValue !== currentPasswordValue && newPasswordValue === confirmPassword;

setIsNewPasswordValid(isMatched);

return !isMatched;
}

isMatched = currentPasswordValue === newPasswordValue;

setIsNewPasswordValid(!isMatched);

return isMatched;
}}
>
{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-error"
match="valueMissing"
>
{t('notEmptyMessage')}
</FieldMessage>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-error"
match={(confirmPassword: string, formData: FormData) => {
const newPassword = formData.get('new-password');
const isMatched = confirmPassword === newPassword;

setIsConfirmPasswordValid(isMatched);

return !isMatched;
}}
>
{t('confirmPasswordValidationMessage')}
</FieldMessage>
</Field>
<div className="flex flex-col justify-start gap-4 md:flex-row">
<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
<Button asChild className="w-full md:w-fit" variant="secondary">
<Link href="/account/settings">{t('cancel')}</Link>
</Button>
</div>
</Form>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';

import { TabType } from '../layout';

import { ChangePasswordForm } from './change-password-form';
import { TabHeading } from './tab-heading';

interface Props {
title: TabType;
action?: string | string[];
}

export const SettingsContent = async ({ title, action }: Props) => {
const locale = await getLocale();
const messages = await getMessages({ locale });

if (action === 'change-password') {
return (
<div className="mx-auto lg:w-2/3">
<TabHeading heading={action} locale={locale} />
<NextIntlClientProvider locale={locale} messages={{ Account: messages.Account ?? {} }}>
<ChangePasswordForm />
</NextIntlClientProvider>
</div>
);
}

return <TabHeading heading={title} locale={locale} />;
};
Loading

0 comments on commit b09b19c

Please sign in to comment.