Skip to content

Commit fcb946e

Browse files
authored
fix(form): display store password complexity requirements in registration form tooltip (#2814)
1 parent b4b87a3 commit fcb946e

File tree

7 files changed

+135
-24
lines changed

7 files changed

+135
-24
lines changed

.changeset/sixty-toes-leave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": patch
3+
---
4+
5+
Shoppers will now see the store's actual password complexity requirements in the tooltip on the new customer registration form, preventing confusion and failed registration attempts. The schema() function in core/vibes/soul/form/dynamic-form/schema.ts now accepts an optional second parameter passwordComplexity to enable dynamic password validation. The DynamicForm, DynamicFormSection components and their associated server actions also accept an optional passwordComplexity prop that flows through to the schema. Action Required: If you have custom registration or password forms and want to use store-specific password complexity settings, fetch passwordComplexitySettings from the GraphQL API (under site.settings.customers.passwordComplexitySettings) and pass it to your DynamicFormSection component and maintain it in your server action's state. If you don't pass it, password validation defaults to: minimum 8 characters, at least one number, and at least one special character. Conflict Resolution: If merging into custom forms, ensure the passwordComplexity prop is threaded through: Page → DynamicFormSection → DynamicForm → useActionState → schema(). In server actions, add passwordComplexity?: Parameters<typeof schema>[1] to your state type and include it in all return statements to maintain state consistency.

core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,19 +335,26 @@ function parseRegisterCustomerInput(
335335
}
336336

337337
export async function registerCustomer<F extends Field>(
338-
prevState: { lastResult: SubmissionResult | null; fields: Array<F | FieldGroup<F>> },
338+
prevState: {
339+
lastResult: SubmissionResult | null;
340+
fields: Array<F | FieldGroup<F>>;
341+
passwordComplexity?: Parameters<typeof schema>[1];
342+
},
339343
formData: FormData,
340344
) {
341345
const t = await getTranslations('Auth.Register');
342346
const locale = await getLocale();
343347
const cartId = await getCartId();
344348

345-
const submission = parseWithZod(formData, { schema: schema(prevState.fields) });
349+
const submission = parseWithZod(formData, {
350+
schema: schema(prevState.fields, prevState.passwordComplexity),
351+
});
346352

347353
if (submission.status !== 'success') {
348354
return {
349355
lastResult: submission.reply(),
350356
fields: prevState.fields,
357+
passwordComplexity: prevState.passwordComplexity,
351358
};
352359
}
353360

@@ -369,6 +376,7 @@ export async function registerCustomer<F extends Field>(
369376
formErrors: response.data.customer.registerCustomer.errors.map((error) => error.message),
370377
}),
371378
fields: prevState.fields,
379+
passwordComplexity: prevState.passwordComplexity,
372380
};
373381
}
374382

@@ -390,19 +398,22 @@ export async function registerCustomer<F extends Field>(
390398
formErrors: error.errors.map(({ message }) => message),
391399
}),
392400
fields: prevState.fields,
401+
passwordComplexity: prevState.passwordComplexity,
393402
};
394403
}
395404

396405
if (error instanceof Error) {
397406
return {
398407
lastResult: submission.reply({ formErrors: [error.message] }),
399408
fields: prevState.fields,
409+
passwordComplexity: prevState.passwordComplexity,
400410
};
401411
}
402412

403413
return {
404414
lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }),
405415
fields: prevState.fields,
416+
passwordComplexity: prevState.passwordComplexity,
406417
};
407418
}
408419

core/app/[locale]/(default)/(auth)/register/page-data.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ const RegisterCustomerQuery = graphql(
1515
) {
1616
site {
1717
settings {
18+
customers {
19+
passwordComplexitySettings {
20+
minimumNumbers
21+
minimumPasswordLength
22+
minimumSpecialCharacters
23+
requireLowerCase
24+
requireNumbers
25+
requireSpecialCharacters
26+
requireUpperCase
27+
}
28+
}
1829
formFields {
1930
customer(filters: $customerFilters, sortBy: $customerSortBy) {
2031
...FormFieldsFragment
@@ -68,6 +79,8 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop
6879
const addressFields = response.data.site.settings?.formFields.shippingAddress;
6980
const customerFields = response.data.site.settings?.formFields.customer;
7081
const countries = response.data.geography.countries;
82+
const passwordComplexitySettings =
83+
response.data.site.settings?.customers?.passwordComplexitySettings;
7184

7285
if (!addressFields || !customerFields) {
7386
return null;
@@ -77,5 +90,6 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop
7790
addressFields,
7891
customerFields,
7992
countries,
93+
passwordComplexitySettings,
8094
};
8195
});

core/app/[locale]/(default)/(auth)/register/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export default async function Register({ params }: Props) {
6060
notFound();
6161
}
6262

63-
const { addressFields, customerFields, countries } = registerCustomerData;
63+
const { addressFields, customerFields, countries, passwordComplexitySettings } =
64+
registerCustomerData;
6465

6566
const fields = transformFieldsToLayout(
6667
[
@@ -109,6 +110,7 @@ export default async function Register({ params }: Props) {
109110
<DynamicFormSection
110111
action={registerCustomer}
111112
fields={fields}
113+
passwordComplexity={passwordComplexitySettings}
112114
submitLabel={t('cta')}
113115
title={t('heading')}
114116
/>

core/vibes/soul/form/dynamic-form/index.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ import { SwatchRadioGroup } from '@/vibes/soul/form/swatch-radio-group';
3636
import { Textarea } from '@/vibes/soul/form/textarea';
3737
import { Button, ButtonProps } from '@/vibes/soul/primitives/button';
3838

39-
import { Field, FieldGroup, schema } from './schema';
39+
import { Field, FieldGroup, PasswordComplexitySettings, schema } from './schema';
4040

4141
type Action<S, P> = (state: Awaited<S>, payload: P) => S | Promise<S>;
4242

4343
interface State<F extends Field> {
4444
fields: Array<F | FieldGroup<F>>;
4545
lastResult: SubmissionResult | null;
4646
successMessage?: ReactNode;
47+
passwordComplexity?: PasswordComplexitySettings | null;
4748
}
4849

4950
export type DynamicFormAction<F extends Field> = Action<State<F>, FormData>;
@@ -59,6 +60,7 @@ export interface DynamicFormProps<F extends Field> {
5960
onCancel?: (e: MouseEvent<HTMLButtonElement>) => void;
6061
onChange?: (e: FormEvent<HTMLFormElement>) => void;
6162
onSuccess?: (lastResult: SubmissionResult, successMessage: ReactNode) => void;
63+
passwordComplexity?: PasswordComplexitySettings | null;
6264
}
6365

6466
export function DynamicForm<F extends Field>({
@@ -72,13 +74,18 @@ export function DynamicForm<F extends Field>({
7274
onCancel,
7375
onChange,
7476
onSuccess,
77+
passwordComplexity: defaultPasswordComplexity,
7578
}: DynamicFormProps<F>) {
76-
const [{ lastResult, fields, successMessage }, formAction] = useActionState(action, {
77-
fields: defaultFields,
78-
lastResult: null,
79-
});
79+
const [{ lastResult, fields, successMessage, passwordComplexity }, formAction] = useActionState(
80+
action,
81+
{
82+
fields: defaultFields,
83+
lastResult: null,
84+
passwordComplexity: defaultPasswordComplexity,
85+
},
86+
);
8087

81-
const dynamicSchema = schema(fields);
88+
const dynamicSchema = schema(fields, passwordComplexity);
8289
const defaultValue = fields
8390
.flatMap((f) => (Array.isArray(f) ? f : [f]))
8491
.reduce<z.infer<typeof dynamicSchema>>(

core/vibes/soul/form/dynamic-form/schema.ts

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { z } from 'zod';
22

3+
export interface PasswordComplexitySettings {
4+
minimumNumbers?: number | null;
5+
minimumPasswordLength?: number | null;
6+
minimumSpecialCharacters?: number | null;
7+
requireLowerCase?: boolean | null;
8+
requireNumbers?: boolean | null;
9+
requireSpecialCharacters?: boolean | null;
10+
requireUpperCase?: boolean | null;
11+
}
12+
313
interface FormField {
414
name: string;
515
label?: string;
@@ -151,7 +161,8 @@ export type SchemaRawShape = Record<
151161
| z.ZodOptional<z.ZodArray<z.ZodString>>
152162
>;
153163

154-
function getFieldSchema(field: Field) {
164+
// eslint-disable-next-line complexity
165+
function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySettings | null) {
155166
let fieldSchema:
156167
| z.ZodString
157168
| z.ZodNumber
@@ -183,20 +194,67 @@ function getFieldSchema(field: Field) {
183194

184195
break;
185196

186-
case 'password':
187-
fieldSchema = z
188-
.string()
189-
.min(8, { message: 'Be at least 8 characters long' })
190-
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
191-
.regex(/[0-9]/, { message: 'Contain at least one number.' })
192-
.regex(/[^a-zA-Z0-9]/, {
197+
case 'password': {
198+
const minLength = passwordComplexity?.minimumPasswordLength ?? 8;
199+
const minNumbers = passwordComplexity?.minimumNumbers ?? 0;
200+
const minSpecialChars = passwordComplexity?.minimumSpecialCharacters ?? 0;
201+
const requireLowerCase = passwordComplexity?.requireLowerCase ?? false;
202+
const requireUpperCase = passwordComplexity?.requireUpperCase ?? false;
203+
const requireNumbers = passwordComplexity?.requireNumbers ?? true;
204+
const requireSpecialChars = passwordComplexity?.requireSpecialCharacters ?? true;
205+
206+
fieldSchema = z.string().trim();
207+
208+
fieldSchema = fieldSchema.min(minLength, {
209+
message: `Be at least ${minLength} character${minLength !== 1 ? 's' : ''} long`,
210+
});
211+
212+
if (requireLowerCase) {
213+
fieldSchema = fieldSchema.regex(/[a-z]/, {
214+
message: 'Contain at least one lowercase letter.',
215+
});
216+
}
217+
218+
if (requireUpperCase) {
219+
fieldSchema = fieldSchema.regex(/[A-Z]/, {
220+
message: 'Contain at least one uppercase letter.',
221+
});
222+
}
223+
224+
if (requireNumbers && minNumbers > 0) {
225+
const numberRegex = new RegExp(`(.*[0-9]){${minNumbers},}`);
226+
227+
fieldSchema = fieldSchema.regex(numberRegex, {
228+
message:
229+
minNumbers === 1
230+
? 'Contain at least one number.'
231+
: `Contain at least ${minNumbers} numbers.`,
232+
});
233+
} else if (requireNumbers) {
234+
fieldSchema = fieldSchema.regex(/[0-9]/, {
235+
message: 'Contain at least one number.',
236+
});
237+
}
238+
239+
if (requireSpecialChars && minSpecialChars > 0) {
240+
const specialCharRegex = new RegExp(`(.*[^a-zA-Z0-9]){${minSpecialChars},}`);
241+
242+
fieldSchema = fieldSchema.regex(specialCharRegex, {
243+
message:
244+
minSpecialChars === 1
245+
? 'Contain at least one special character.'
246+
: `Contain at least ${minSpecialChars} special characters.`,
247+
});
248+
} else if (requireSpecialChars) {
249+
fieldSchema = fieldSchema.regex(/[^a-zA-Z0-9]/, {
193250
message: 'Contain at least one special character.',
194-
})
195-
.trim();
251+
});
252+
}
196253

197254
if (field.required !== true) fieldSchema = fieldSchema.optional();
198255

199256
break;
257+
}
200258

201259
case 'email':
202260
fieldSchema = z.string().email({ message: 'Please enter a valid email.' }).trim();
@@ -221,21 +279,24 @@ function getFieldSchema(field: Field) {
221279
return fieldSchema;
222280
}
223281

224-
export function schema(fields: Array<Field | FieldGroup<Field>>) {
282+
export function schema(
283+
fields: Array<Field | FieldGroup<Field>>,
284+
passwordComplexity?: PasswordComplexitySettings | null,
285+
) {
225286
const shape: SchemaRawShape = {};
226287
let passwordFieldName: string | undefined;
227288
let confirmPasswordFieldName: string | undefined;
228289

229290
fields.forEach((field) => {
230291
if (Array.isArray(field)) {
231292
field.forEach((f) => {
232-
shape[f.name] = getFieldSchema(f);
293+
shape[f.name] = getFieldSchema(f, passwordComplexity);
233294

234295
if (f.type === 'password') passwordFieldName = f.name;
235296
if (f.type === 'confirm-password') confirmPasswordFieldName = f.name;
236297
});
237298
} else {
238-
shape[field.name] = getFieldSchema(field);
299+
shape[field.name] = getFieldSchema(field, passwordComplexity);
239300

240301
if (field.type === 'password') passwordFieldName = field.name;
241302
if (field.type === 'confirm-password') confirmPasswordFieldName = field.name;

core/vibes/soul/sections/dynamic-form-section/index.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { clsx } from 'clsx';
22

33
import { DynamicForm, DynamicFormAction } from '@/vibes/soul/form/dynamic-form';
4-
import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema';
4+
import {
5+
Field,
6+
FieldGroup,
7+
PasswordComplexitySettings,
8+
} from '@/vibes/soul/form/dynamic-form/schema';
59
import { SectionLayout } from '@/vibes/soul/sections/section-layout';
610

711
interface Props<F extends Field> {
@@ -11,6 +15,7 @@ interface Props<F extends Field> {
1115
fields: Array<F | FieldGroup<F>>;
1216
submitLabel?: string;
1317
className?: string;
18+
passwordComplexity?: PasswordComplexitySettings | null;
1419
}
1520

1621
export function DynamicFormSection<F extends Field>({
@@ -20,6 +25,7 @@ export function DynamicFormSection<F extends Field>({
2025
fields,
2126
submitLabel,
2227
action,
28+
passwordComplexity,
2329
}: Props<F>) {
2430
return (
2531
<SectionLayout className={clsx('mx-auto w-full max-w-4xl', className)} containerSize="lg">
@@ -33,7 +39,12 @@ export function DynamicFormSection<F extends Field>({
3339
)}
3440
</header>
3541
)}
36-
<DynamicForm action={action} fields={fields} submitLabel={submitLabel} />
42+
<DynamicForm
43+
action={action}
44+
fields={fields}
45+
passwordComplexity={passwordComplexity}
46+
submitLabel={submitLabel}
47+
/>
3748
</SectionLayout>
3849
);
3950
}

0 commit comments

Comments
 (0)