diff --git a/.changeset/happy-eggs-buy.md b/.changeset/happy-eggs-buy.md new file mode 100644 index 000000000..c498c1553 --- /dev/null +++ b/.changeset/happy-eggs-buy.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +add change password for logged-in customer diff --git a/core/app/[locale]/(default)/account/[tab]/_actions/submit-customer-change-password-form.ts b/core/app/[locale]/(default)/account/[tab]/_actions/submit-customer-change-password-form.ts new file mode 100644 index 000000000..089490780 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_actions/submit-customer-change-password-form.ts @@ -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.' }; + } +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/addresses-content.tsx b/core/app/[locale]/(default)/account/[tab]/_components/addresses-content.tsx index b5a47d34a..c1e4a2f9d 100644 --- a/core/app/[locale]/(default)/account/[tab]/_components/addresses-content.tsx +++ b/core/app/[locale]/(default)/account/[tab]/_components/addresses-content.tsx @@ -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'; @@ -23,7 +23,7 @@ export const AddressesContent = async ({ addresses, pageInfo, title }: Props) => return ( <> - {tabHeading(title, locale)} + ; + +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 ( + + ); +}; + +export const ChangePasswordForm = () => { + const router = useRouter(); + const form = useRef(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) => + setIsCurrentPasswordValid(!e.target.validity.valueMissing); + const handleNewPasswordChange = (e: ChangeEvent) => { + 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) => { + 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') && ( + +

{messageText}

+
+ )} + +
+ + + {t('currentPasswordLabel')} + + + + + + {t('notEmptyMessage')} + + + + + {t('newPasswordLabel')} + + + + + + {t('notEmptyMessage')} + + { + 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')} + + + + + {t('confirmPasswordLabel')} + + + + + + {t('notEmptyMessage')} + + { + const newPassword = formData.get('new-password'); + const isMatched = confirmPassword === newPassword; + + setIsConfirmPasswordValid(isMatched); + + return !isMatched; + }} + > + {t('confirmPasswordValidationMessage')} + + +
+ + + + +
+
+ + ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/settings-content.tsx b/core/app/[locale]/(default)/account/[tab]/_components/settings-content.tsx new file mode 100644 index 000000000..5ba292ecd --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/settings-content.tsx @@ -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 ( +
+ + + + +
+ ); + } + + return ; +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx b/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx new file mode 100644 index 000000000..31ed22152 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from 'next-intl/server'; + +import { TabType } from '../layout'; + +export const TabHeading = async ({ + heading, + locale, +}: { + heading: TabType | 'change-password'; + locale: string; +}) => { + const t = await getTranslations({ locale, namespace: 'Account.Home' }); + const tab = heading === 'recently-viewed' ? 'recentlyViewed' : heading; + const title = tab === 'change-password' ? 'changePassword' : tab; + + return

{t(title)}

; +}; diff --git a/core/app/[locale]/(default)/account/[tab]/page.tsx b/core/app/[locale]/(default)/account/[tab]/page.tsx index b474d2883..b22dd54dd 100644 --- a/core/app/[locale]/(default)/account/[tab]/page.tsx +++ b/core/app/[locale]/(default)/account/[tab]/page.tsx @@ -6,6 +6,8 @@ import { getCustomerAddresses } from '~/client/queries/get-customer-addresses'; import { LocaleType } from '~/i18n'; import { AddressesContent } from './_components/addresses-content'; +import { SettingsContent } from './_components/settings-content'; +import { TabHeading } from './_components/tab-heading'; import { TabType } from './layout'; interface Props { @@ -28,19 +30,13 @@ export async function generateMetadata({ params: { tab, locale } }: Props): Prom }; } -const tabHeading = async (heading: string, locale: string) => { - const t = await getTranslations({ locale, namespace: 'Account.Home' }); - - return

{t(heading)}

; -}; - export default async function AccountTabPage({ params: { tab, locale }, searchParams }: Props) { switch (tab) { case 'orders': - return tabHeading(tab, locale); + return ; case 'messages': - return tabHeading(tab, locale); + return ; case 'addresses': { const { before, after } = searchParams; @@ -60,18 +56,18 @@ export default async function AccountTabPage({ params: { tab, locale }, searchPa } case 'wishlists': - return tabHeading(tab, locale); + return ; case 'recently-viewed': - return tabHeading('recentlyViewed', locale); + return ; - case 'settings': - return tabHeading(tab, locale); + case 'settings': { + return ; + } default: return notFound(); } } -export { tabHeading }; export const runtime = 'edge'; diff --git a/core/client/mutations/submit-change-password.ts b/core/client/mutations/submit-change-password.ts index d94356434..300cc3209 100644 --- a/core/client/mutations/submit-change-password.ts +++ b/core/client/mutations/submit-change-password.ts @@ -1,22 +1,24 @@ import { z } from 'zod'; import { client } from '..'; -import { graphql } from '../graphql'; - -export const ChangePasswordSchema = z - .object({ - customerId: z.string(), - customerToken: z.string(), - newPassword: z.string(), - confirmPassword: z.string(), - }) - .required(); - -interface SubmitChangePassword { - newPassword: z.infer['newPassword']; - token: string; - customerEntityId: number; -} +import { graphql, VariablesOf } from '../graphql'; + +const ChangePasswordFieldsSchema = z.object({ + customerId: z.string(), + customerToken: z.string(), + currentPassword: z.string().min(1), + newPassword: z.string().min(1), + confirmPassword: z.string().min(1), +}); + +export const CustomerChangePasswordSchema = ChangePasswordFieldsSchema.omit({ + customerId: true, + customerToken: true, +}); + +export const ChangePasswordSchema = ChangePasswordFieldsSchema.omit({ + currentPassword: true, +}).required(); const SUBMIT_CHANGE_PASSWORD_MUTATION = graphql(` mutation ChangePassword($input: ResetPasswordInput!) { @@ -34,11 +36,13 @@ const SUBMIT_CHANGE_PASSWORD_MUTATION = graphql(` } `); +type ChangePasswordInput = VariablesOf['input']; + export const submitChangePassword = async ({ newPassword, token, customerEntityId, -}: SubmitChangePassword) => { +}: ChangePasswordInput) => { const variables = { input: { token, diff --git a/core/client/mutations/submit-customer-change-password.ts b/core/client/mutations/submit-customer-change-password.ts index ddbdd1b20..8a20100e2 100644 --- a/core/client/mutations/submit-customer-change-password.ts +++ b/core/client/mutations/submit-customer-change-password.ts @@ -1,21 +1,7 @@ -import { z } from 'zod'; - import { getSessionCustomerId } from '~/auth'; import { client } from '..'; -import { graphql } from '../graphql'; - -export const CustomerChangePasswordSchema = z - .object({ - currentPassword: z.string(), - newPassword: z.string(), - confirmPassword: z.string(), - }) - .required(); - -const Input = CustomerChangePasswordSchema.omit({ confirmPassword: true }); - -type SubmitCustomerChangePassword = z.infer; +import { graphql, VariablesOf } from '../graphql'; const SUBMIT_CUSTOMER_CHANGE_PASSWORD_MUTATION = graphql(` mutation CustomerChangePassword($input: ChangePasswordInput!) { @@ -41,10 +27,12 @@ const SUBMIT_CUSTOMER_CHANGE_PASSWORD_MUTATION = graphql(` } `); +type Variables = VariablesOf; + export const submitCustomerChangePassword = async ({ currentPassword, newPassword, -}: SubmitCustomerChangePassword) => { +}: Variables['input']) => { const customerId = await getSessionCustomerId(); const variables = { input: { diff --git a/core/messages/en.json b/core/messages/en.json index a87374053..3d63b516c 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -183,7 +183,8 @@ "addresses": "Addresses", "wishlists": "Wish lists", "recentlyViewed": "Recently viewed", - "settings": "Account settings" + "settings": "Account settings", + "changePassword": "Change password" }, "Addresses": { "defaultAddress": "Default", @@ -218,9 +219,13 @@ } }, "ChangePassword": { + "currentPasswordLabel": "Current password", "newPasswordLabel": "New password", "confirmPasswordLabel": "Confirm password", + "cancel": "Cancel", "confirmPasswordValidationMessage": "Entered passwords are mismatched. Please try again.", + "newPasswordValidationMessage": "New password must be different from the current password or/and match confirm password.", + "notEmptyMessage": "Field should not be empty", "successMessage": "Password has been updated successfully!" }, "SubmitChangePassword": {