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 Mar 27, 2024
1 parent a1f7970 commit 7098eff
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 29 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
23 changes: 21 additions & 2 deletions apps/core/app/[locale]/(default)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { BookUser, Eye, Gift, Mail, Package, Settings } from 'lucide-react';
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { ReactNode } from 'react';

import { auth } from '~/auth';
import { Link } from '~/components/link';
import { LocaleType } from '~/i18n';

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

interface AccountItem {
children: ReactNode;
description?: string;
Expand All @@ -33,16 +36,32 @@ interface Props {
params: {
locale: LocaleType;
};
searchParams: {
action?: 'reset_password';
};
}

export default async function AccountPage({ params: { locale } }: Props) {
export default async function AccountPage({ params: { locale }, searchParams: { action } }: Props) {
const session = await auth();
const t = await getTranslations({ locale, namespace: 'Account.Home' });
const messages = await getMessages({ locale });
const Account = messages.Account ?? {};

if (!session) {
redirect('/login');
}

if (action === 'reset_password') {
return (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="mb-8 text-4xl font-black lg:text-5xl">{t('changePassword')}</h2>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<ChangePasswordForm isLoggedIn={true} />
</NextIntlClientProvider>
</div>
);
}

return (
<div className="mx-auto max-w-screen-xl">
<h1 className="my-6 my-8 text-4xl font-black lg:my-8 lg:text-5xl">{t('heading')}</h1>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use server';

import { ZodError } from 'zod';

import {
CustomerChangePasswordSchema,
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: '' };
}

return {
status: 'error',
message: response.errors.map((error) => error.message).join('\n'),
};
} catch (error: unknown) {
if (error instanceof 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 @@ -16,13 +16,16 @@ import { useTranslations } from 'next-intl';
import { ChangeEvent, useRef, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';

import { logout } from '~/components/header/_actions/logout';
import { useRouter } from '~/navigation';

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

interface Props {
customerId: number;
customerToken: string;
customerId?: number;
customerToken?: string;
isLoggedIn: boolean;
}

const SubmitButton = () => {
Expand Down Expand Up @@ -51,18 +54,20 @@ const SubmitButton = () => {
);
};

export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
export const ChangePasswordForm = ({ customerId, customerToken, isLoggedIn }: Props) => {
const form = useRef<HTMLFormElement>(null);
const t = useTranslations('Account.ChangePassword');
const router = useRouter();
const [state, formAction] = useFormState(submitChangePasswordForm, {
const submitFormAction = isLoggedIn ? submitCustomerChangePasswordForm : submitChangePasswordForm;
const [state, formAction] = useFormState(submitFormAction, {
status: 'idle',
message: '',
});

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

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

if (state.status === 'error') {
Expand All @@ -73,18 +78,46 @@ export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
messageText = t('successMessage');
}

const handleNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) =>
setNewPasssword(e.target.value);
const handleCurrentPasswordChange = (e: ChangeEvent<HTMLInputElement>) =>
setIsCurrentPasswordValid(!e.target.validity.valueMissing);
const handleNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
let currentPasswordValue: FormDataEntryValue | null = null;
let isValid = true;
const newPasswordValue = e.target.value;

if (e.target.form) {
currentPasswordValue = new FormData(e.target.form).get('current-password');
}

if (isLoggedIn) {
isValid = !e.target.validity.valueMissing && newPasswordValue !== currentPasswordValue;
} else {
isValid = !e.target.validity.valueMissing;
}

setIsNewPasswordValid(isValid);
};
const handleConfirmPasswordValidation = (e: ChangeEvent<HTMLInputElement>) => {
const confirmPassword = e.target.value;
let newPasswordValue: FormDataEntryValue | null = null;
const confirmPasswordValue = e.target.value;

return setIsConfirmPasswordValid(confirmPassword === newPassword);
if (e.target.form) {
newPasswordValue = new FormData(e.target.form).get('new-password');
}

setIsConfirmPasswordValid(
confirmPasswordValue.length > 0 && newPasswordValue === confirmPasswordValue,
);
};

if (state.status === 'success') {
if (state.status === 'success' && !isLoggedIn) {
setTimeout(() => router.push('/login'), 2000);
}

if (state.status === 'success' && isLoggedIn) {
void logout();
}

return (
<>
{(state.status === 'error' || state.status === 'success') && (
Expand All @@ -94,16 +127,44 @@ export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
)}

<Form action={formAction} className="mb-14 flex flex-col gap-4 md:py-4 lg:p-0" ref={form}>
<Field className="hidden" name="customer-id">
<FieldControl asChild>
<Input id="customer-id" readOnly type="number" value={customerId} />
</FieldControl>
</Field>
<Field className="hidden" name="customer-token">
<FieldControl asChild>
<Input id="customer-token" readOnly type="text" value={customerToken} />
</FieldControl>
</Field>
{Boolean(customerId) && (
<Field className="hidden" name="customer-id">
<FieldControl asChild>
<Input id="customer-id" readOnly type="number" value={customerId} />
</FieldControl>
</Field>
)}
{Boolean(customerToken) && (
<Field className="hidden" name="customer-token">
<FieldControl asChild>
<Input id="customer-token" readOnly type="text" value={customerToken} />
</FieldControl>
</Field>
)}
{isLoggedIn && (
<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 || state.status === 'error' ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
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')}
Expand All @@ -113,13 +174,34 @@ export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
autoComplete="none"
id="new-password"
onChange={handleNewPasswordChange}
onInvalid={handleNewPasswordChange}
required
type="password"
variant={state.status === 'error' ? 'error' : undefined}
variant={!isNewPasswordValid || state.status === 'error' ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match="valueMissing"
>
{t('notEmptyMessage')}
</FieldMessage>
{isLoggedIn && (
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match={(newPasswordValue: string, formData: FormData) => {
const currentPasswordValue = formData.get('current-password');
const 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')}
Expand All @@ -137,12 +219,24 @@ export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match={(value: string) => value !== newPassword}
match="valueMissing"
>
{t('notEmptyMessage')}
</FieldMessage>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match={(confirmPasswordValue: string, formData: FormData) => {
const newPasswordValue = formData.get('new-password');
const isMatched = confirmPasswordValue === newPasswordValue;

setIsConfirmPasswordValid(isMatched);

return !isMatched;
}}
>
{t('confirmPasswordValidationMessage')}
</FieldMessage>
</Field>

<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
Expand Down
6 changes: 5 additions & 1 deletion apps/core/app/[locale]/(default)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export default async function Login({ params: { locale }, searchParams }: Props)
<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} />
<ChangePasswordForm
customerId={Number(customerId)}
customerToken={customerToken}
isLoggedIn={false}
/>
</NextIntlClientProvider>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@
"addresses": "Addresses",
"wishlists": "Wish lists",
"recentlyViewed": "Recently viewed",
"accountSettings": "Account settings"
"accountSettings": "Account settings",
"changePassword": "Change password"
},
"Login": {
"heading": "Log In",
Expand Down Expand Up @@ -183,9 +184,12 @@
}
},
"ChangePassword": {
"currentPasswordLabel": "Current password",
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordValidationMessage": "Entered passwords are mismatched. Please try again.",
"newPasswordValidationMessage": "New password must be different from the current password.",
"notEmptyMessage": "Field should not be empty",
"successMessage": "Password has been updated successfully!"
},
"SubmitChangePassword": {
Expand Down

0 comments on commit 7098eff

Please sign in to comment.