diff --git a/.changeset/fair-olives-wish.md b/.changeset/fair-olives-wish.md
new file mode 100644
index 00000000000..197e89efe5e
--- /dev/null
+++ b/.changeset/fair-olives-wish.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Bugfix: Fixed incorrect field validation when using password authentication with email or phone number during sign-up. Optional email and phone fields now correctly display their requirement status.
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 3edd92caf01..3a0c7d1afee 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -15,7 +15,7 @@
{ "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" },
{ "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" },
{ "path": "./dist/signin*.js", "maxSize": "14KB" },
- { "path": "./dist/signup*.js", "maxSize": "8.5KB" },
+ { "path": "./dist/signup*.js", "maxSize": "8.86KB" },
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },
diff --git a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.spec.tsx b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.spec.tsx
index 2e4b61ad97b..04d92304e34 100644
--- a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.spec.tsx
+++ b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.spec.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { CardStateProvider } from '@/ui/elements/contexts';
-import { render, screen, waitFor } from '../../../../vitestUtils';
+import { fireEvent, render, screen, waitFor } from '../../../../vitestUtils';
import { OptionsProvider } from '../../../contexts';
import { AppearanceProvider } from '../../../customizables';
import { bindCreateFixtures } from '../../../utils/vitest/createFixtures';
@@ -88,14 +88,83 @@ describe('SignUpStart', () => {
screen.getByText('Password');
});
- it('enables optional email', async () => {
+ it('should keep email optional when phone is primary with password', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress({ required: false });
f.withPhoneNumber({ required: true });
f.withPassword({ required: true });
});
render(, { wrapper });
- expect(screen.getByText('Email address').nextElementSibling?.textContent).toBe('Optional');
+
+ const emailAddress = screen.getByLabelText('Email address', { selector: 'input' });
+ expect(emailAddress.ariaRequired).toBe('false');
+ expect(screen.getByText('Optional')).toBeInTheDocument();
+
+ const phoneInput = screen.getByLabelText('Phone number', { selector: 'input' });
+ expect(phoneInput.ariaRequired).toBe('true');
+ });
+
+ it('should require phone when password is required and phone is primary communication method', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPhoneNumber({ required: false, used_for_first_factor: true });
+ f.withPassword({ required: true });
+ });
+ render(, { wrapper });
+
+ const phoneInput = screen.getByLabelText('Phone number', { selector: 'input' });
+ expect(phoneInput.ariaRequired).toBe('true');
+
+ expect(screen.queryByLabelText('Email address', { selector: 'input' })).not.toBeInTheDocument();
+ });
+
+ it('should require email when only email is enabled with password', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress({ required: true, used_for_first_factor: true });
+ f.withPassword({ required: true });
+ });
+
+ render(, { wrapper });
+
+ const emailAddress = screen.getByLabelText('Email address', { selector: 'input' });
+ expect(emailAddress.ariaRequired).toBe('true');
+ expect(screen.queryByText('Optional')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Phone number', { selector: 'input' })).not.toBeInTheDocument();
+ });
+
+ it('should require email when password is required and email is primary communication method', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress({ required: false, used_for_first_factor: true });
+ f.withPhoneNumber({ required: false, used_for_first_factor: false });
+ f.withPassword({ required: true });
+ });
+ render(, { wrapper });
+
+ const emailAddress = screen.getByLabelText('Email address', { selector: 'input' });
+ expect(emailAddress.ariaRequired).toBe('true');
+ expect(screen.queryByText('Optional')).not.toBeInTheDocument();
+
+ expect(screen.queryByLabelText('Phone number', { selector: 'input' })).not.toBeInTheDocument();
+ });
+
+ it('should require active field when toggling between email and phone with password', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress({ required: false, used_for_first_factor: true });
+ f.withPhoneNumber({ required: false, used_for_first_factor: true });
+ f.withPassword({ required: true });
+ });
+ render(, { wrapper });
+
+ const emailInput = screen.getByLabelText('Email address', { selector: 'input' });
+ expect(emailInput.ariaRequired).toBe('true');
+ expect(screen.queryByText('Optional')).not.toBeInTheDocument();
+
+ const usePhoneButton = screen.getByText(/use phone/i);
+ fireEvent.click(usePhoneButton);
+
+ const phoneInput = screen.getByLabelText('Phone number', { selector: 'input' });
+ expect(phoneInput.ariaRequired).toBe('true');
+
+ expect(screen.queryByLabelText('Email address', { selector: 'input' })).not.toBeInTheDocument();
});
it('enables optional phone number', async () => {
diff --git a/packages/clerk-js/src/ui/components/SignUp/__tests__/signUpFormHelpers.test.ts b/packages/clerk-js/src/ui/components/SignUp/__tests__/signUpFormHelpers.test.ts
index 6063909f14b..bb55e2d8f15 100644
--- a/packages/clerk-js/src/ui/components/SignUp/__tests__/signUpFormHelpers.test.ts
+++ b/packages/clerk-js/src/ui/components/SignUp/__tests__/signUpFormHelpers.test.ts
@@ -1,6 +1,6 @@
import type { Attribute } from '@clerk/types';
-import { determineActiveFields, getInitialActiveIdentifier } from '../signUpFormHelpers';
+import { determineActiveFields, determineRequiredIdentifier, getInitialActiveIdentifier } from '../signUpFormHelpers';
const createAttributeData = (name: Attribute, enabled: boolean, required: boolean, usedForFirstFactor: boolean) => ({
name,
@@ -536,6 +536,427 @@ describe('determineActiveFields()', () => {
expect(res).toEqual(result);
});
+
+ describe('email or phone requirements with password', () => {
+ type Scenario = [string, any, any];
+ const scenaria: Scenario[] = [
+ [
+ 'email optional, phone primary required with password',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: true,
+ },
+ phone_number: {
+ enabled: true,
+ required: true,
+ used_for_first_factor: true,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ emailAddress: {
+ required: false,
+ disabled: false,
+ },
+ phoneNumber: {
+ required: true,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ [
+ 'phone optional, email required with password',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: true,
+ required: true,
+ used_for_first_factor: true,
+ },
+ phone_number: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: true,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ emailAddress: {
+ required: true,
+ disabled: false,
+ },
+ phoneNumber: {
+ required: false,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ [
+ 'email and phone required with password',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: true,
+ required: true,
+ used_for_first_factor: true,
+ },
+ phone_number: {
+ enabled: true,
+ required: true,
+ used_for_first_factor: true,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ emailAddress: {
+ required: true,
+ disabled: false,
+ },
+ phoneNumber: {
+ required: true,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ ];
+
+ it.each(scenaria)('%s', (___, attributes, result) => {
+ const actualResult = determineActiveFields({
+ attributes: attributes,
+ activeCommIdentifierType: getInitialActiveIdentifier(attributes, isProgressiveSignUp),
+ isProgressiveSignUp,
+ });
+
+ expect(actualResult).toEqual(result);
+ });
+ });
+
+ describe('password security: requires communication method', () => {
+ // When password is required but no communication method is explicitly required,
+ // we should automatically require one for password recovery
+ type Scenario = [string, any, any];
+ const scenaria: Scenario[] = [
+ [
+ 'password required, both email and phone optional and neither primary - email takes precedence',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: false,
+ },
+ phone_number: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: false,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ emailAddress: {
+ required: true,
+ disabled: false,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ [
+ 'password required, only email available - email becomes required',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: false,
+ },
+ phone_number: {
+ enabled: false,
+ required: false,
+ used_for_first_factor: false,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ emailAddress: {
+ required: true,
+ disabled: false,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ [
+ 'password required, only phone available - phone becomes required',
+ {
+ ...mockDefaultAttributesProgressive,
+ email_address: {
+ enabled: false,
+ required: false,
+ used_for_first_factor: false,
+ },
+ phone_number: {
+ enabled: true,
+ required: false,
+ used_for_first_factor: false,
+ },
+ password: {
+ enabled: true,
+ required: true,
+ },
+ },
+ {
+ phoneNumber: {
+ required: true,
+ },
+ password: {
+ required: true,
+ },
+ },
+ ],
+ ];
+
+ it.each(scenaria)('%s', (___, attributes, result) => {
+ const actualResult = determineActiveFields({
+ attributes: attributes,
+ activeCommIdentifierType: getInitialActiveIdentifier(attributes, isProgressiveSignUp),
+ isProgressiveSignUp,
+ });
+
+ expect(actualResult).toEqual(result);
+ });
+ });
+
+ describe('password security: requirement follows active field in email OR phone scenarios', () => {
+ // When both email and phone are optional but password is required,
+ // the currently active field should become required
+ it('email required when active in email OR phone scenario with password', () => {
+ const attributes = {
+ email_address: createAttributeData('email_address', true, false, true),
+ phone_number: createAttributeData('phone_number', true, false, true),
+ password: createAttributeData('password', true, true, false),
+ first_name: createAttributeData('first_name', false, false, false),
+ last_name: createAttributeData('last_name', false, false, false),
+ username: createAttributeData('username', false, false, false),
+ };
+
+ const result = determineActiveFields({
+ attributes: attributes,
+ activeCommIdentifierType: 'emailAddress', // Email is currently active
+ isProgressiveSignUp,
+ });
+
+ expect(result).toEqual({
+ emailAddress: {
+ required: true,
+ disabled: false,
+ },
+ password: {
+ required: true,
+ },
+ });
+ });
+
+ it('phone required when active in email OR phone scenario with password', () => {
+ const attributes = {
+ email_address: createAttributeData('email_address', true, false, true),
+ phone_number: createAttributeData('phone_number', true, false, true),
+ password: createAttributeData('password', true, true, false),
+ first_name: createAttributeData('first_name', false, false, false),
+ last_name: createAttributeData('last_name', false, false, false),
+ username: createAttributeData('username', false, false, false),
+ };
+
+ const result = determineActiveFields({
+ attributes: attributes,
+ activeCommIdentifierType: 'phoneNumber', // Phone is currently active
+ isProgressiveSignUp,
+ });
+
+ expect(result).toEqual({
+ phoneNumber: {
+ required: true,
+ },
+ password: {
+ required: true,
+ },
+ });
+ });
+ });
+ });
+});
+
+describe('determineRequiredIdentifier', () => {
+ const createMinimalAttributes = (overrides: any = {}) => ({
+ email_address: createAttributeData('email_address', false, false, false),
+ phone_number: createAttributeData('phone_number', false, false, false),
+ username: createAttributeData('username', false, false, false),
+ password: createAttributeData('password', false, false, false),
+ ...overrides,
+ });
+
+ describe('when password is NOT required', () => {
+ it('mirrors server settings for all fields', () => {
+ const attributes = createMinimalAttributes({
+ password: createAttributeData('password', true, false, false), // password not required
+ email_address: createAttributeData('email_address', true, true, true),
+ phone_number: createAttributeData('phone_number', true, false, false),
+ username: createAttributeData('username', true, true, false),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: true,
+ });
+ });
+ });
+
+ describe('when password IS required', () => {
+ const requiredPassword = { password: createAttributeData('password', true, true, false) };
+
+ it('mirrors server settings if a communication method is already required', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ email_address: createAttributeData('email_address', true, true, true),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires nothing if no communication methods are enabled', () => {
+ const attributes = createMinimalAttributes({ ...requiredPassword });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: false,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires email if it is the only enabled communication method', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ email_address: createAttributeData('email_address', true, false, false),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires phone if it is the only enabled communication method', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ phone_number: createAttributeData('phone_number', true, false, false),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: false,
+ phoneShouldBeRequired: true,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires username if it is the only enabled communication method and a first factor', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ username: createAttributeData('username', true, false, true),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: false,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: true,
+ });
+ });
+
+ it('defaults to requiring both email and phone if both email and phone are enabled but not required', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ email_address: createAttributeData('email_address', true, false, false),
+ phone_number: createAttributeData('phone_number', true, false, false),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: true,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires email by default if no other communication methods are required by instance settings', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ username: createAttributeData('username', true, false, false),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires email when username is a non-required first factor', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ email_address: createAttributeData('email_address', true, false, false),
+ username: createAttributeData('username', true, false, true),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ });
+ });
+
+ it('requires phone when username is a non-required first factor and email is disabled', () => {
+ const attributes = createMinimalAttributes({
+ ...requiredPassword,
+ phone_number: createAttributeData('phone_number', true, false, false),
+ username: createAttributeData('username', true, false, true),
+ });
+ const result = determineRequiredIdentifier(attributes);
+ expect(result).toEqual({
+ emailShouldBeRequired: false,
+ phoneShouldBeRequired: true,
+ usernameShouldBeRequired: false,
+ });
+ });
});
});
diff --git a/packages/clerk-js/src/ui/components/SignUp/signUpFormHelpers.ts b/packages/clerk-js/src/ui/components/SignUp/signUpFormHelpers.ts
index 5922509a3f2..dba5807f168 100644
--- a/packages/clerk-js/src/ui/components/SignUp/signUpFormHelpers.ts
+++ b/packages/clerk-js/src/ui/components/SignUp/signUpFormHelpers.ts
@@ -192,8 +192,10 @@ function getEmailAddressField({
return;
}
+ const { emailShouldBeRequired } = determineRequiredIdentifier(attributes);
+
return {
- required: Boolean(attributes.email_address?.required),
+ required: emailShouldBeRequired,
disabled: !!hasTicket && !!hasEmail,
};
}
@@ -234,8 +236,10 @@ function getPhoneNumberField({
return;
}
+ const { phoneShouldBeRequired } = determineRequiredIdentifier(attributes);
+
return {
- required: Boolean(attributes.phone_number?.required),
+ required: phoneShouldBeRequired,
};
}
@@ -300,3 +304,124 @@ function getGenericField(fieldKey: FieldKey, attributes: Partial): F
required: attributes[attrKey]?.required,
};
}
+
+type Outcome = 'email' | 'phone' | 'username' | 'mirrorServer' | 'none';
+
+type SignUpAttributeField = {
+ enabled: boolean;
+ required: boolean;
+ firstFactor: boolean;
+};
+
+type Context = {
+ passwordRequired: boolean;
+ email: SignUpAttributeField;
+ phone: SignUpAttributeField;
+ username: SignUpAttributeField;
+};
+
+const outcomePredicates: Record boolean)[]> = {
+ mirrorServer: [
+ // If password is not required, then field requirements are determined by the server.
+ ctx => !ctx.passwordRequired,
+ // If any of the identifiers are already required by the server, then we don't need to do anything.
+ ctx => ctx.email.required || ctx.phone.required || (ctx.username.required && ctx.username.firstFactor),
+ ],
+ none: [
+ // If none of the identifiers are enabled, then none can be required.
+ ctx => !ctx.email.enabled && !ctx.phone.enabled && !ctx.username.enabled,
+ ],
+ email: [
+ // If email is the only enabled identifier, it should be required.
+ ctx => ctx.email.enabled && !ctx.phone.enabled && !ctx.username.enabled,
+ // If email is enabled but not required, and phone is enabled and not required, then email should be required.
+ ctx => ctx.email.enabled && !ctx.email.required && ctx.phone.enabled && !ctx.phone.required,
+ // If username is a first factor but not required, email can be used as an alternative.
+ ctx => ctx.username.firstFactor && !ctx.username.required && ctx.email.enabled && !ctx.email.required,
+ // If username is required but not a first factor, and both email and phone are enabled, then email is a valid identifier.
+ ctx => ctx.username.required && !ctx.username.firstFactor && ctx.email.enabled && ctx.phone.enabled,
+ ],
+ phone: [
+ ctx => ctx.phone.enabled && !ctx.email.required && !ctx.phone.required,
+ // If username is a first factor but not required, phone can be used as an alternative.
+ ctx => ctx.username.firstFactor && !ctx.username.required && ctx.phone.enabled && !ctx.phone.required,
+ // If phone is the only first factor, it should be required.
+ ctx => ctx.phone.firstFactor && !ctx.email.firstFactor && !ctx.username.firstFactor,
+ // If username is required but not a first factor, and both email and phone are enabled, then phone is a valid identifier.
+ ctx => ctx.username.required && !ctx.username.firstFactor && ctx.phone.enabled && ctx.email.enabled,
+ // If email is not enabled, but phone and username are, phone should be available.
+ ctx => !ctx.email.enabled && ctx.phone.enabled && ctx.username.enabled,
+ ],
+ username: [
+ // If username is the only first factor, it should be required.
+ ctx => ctx.username.enabled && ctx.username.firstFactor && !ctx.email.enabled && !ctx.phone.enabled,
+ // If username is required but not a first factor, and both email and phone are enabled, it should be required.
+ ctx => ctx.username.required && !ctx.username.firstFactor && ctx.email.enabled && ctx.phone.enabled,
+ ],
+};
+
+/**
+ * When password is required, we need to ensure at least one identifier
+ * (email, phone, or username) is also required
+ */
+export function determineRequiredIdentifier(attributes: Partial): {
+ emailShouldBeRequired: boolean;
+ phoneShouldBeRequired: boolean;
+ usernameShouldBeRequired: boolean;
+} {
+ const ctx = {
+ passwordRequired: Boolean(attributes.password?.enabled && attributes.password.required),
+ email: {
+ enabled: Boolean(attributes.email_address?.enabled),
+ required: Boolean(attributes.email_address?.required),
+ firstFactor: Boolean(attributes.email_address?.used_for_first_factor),
+ },
+ phone: {
+ enabled: Boolean(attributes.phone_number?.enabled),
+ required: Boolean(attributes.phone_number?.required),
+ firstFactor: Boolean(attributes.phone_number?.used_for_first_factor),
+ },
+ username: {
+ enabled: Boolean(attributes.username?.enabled),
+ required: Boolean(attributes.username?.required),
+ firstFactor: Boolean(attributes.username?.used_for_first_factor),
+ },
+ };
+
+ const outcomeMet = (outcome: Outcome) => outcomePredicates[outcome].some(predicate => predicate(ctx));
+
+ if (outcomeMet('mirrorServer')) {
+ return {
+ emailShouldBeRequired: ctx.email.required,
+ phoneShouldBeRequired: ctx.phone.required,
+ usernameShouldBeRequired: ctx.username.required,
+ };
+ }
+
+ if (outcomeMet('none')) {
+ return {
+ emailShouldBeRequired: false,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ };
+ }
+
+ const emailShouldBeRequired = outcomeMet('email');
+ const phoneShouldBeRequired = outcomeMet('phone');
+ const usernameShouldBeRequired = outcomeMet('username');
+
+ // If password is required and no identifier is enabled, then email is the default.
+ if (ctx.passwordRequired && !emailShouldBeRequired && !phoneShouldBeRequired && !usernameShouldBeRequired) {
+ return {
+ emailShouldBeRequired: true,
+ phoneShouldBeRequired: false,
+ usernameShouldBeRequired: false,
+ };
+ }
+
+ return {
+ emailShouldBeRequired,
+ phoneShouldBeRequired,
+ usernameShouldBeRequired,
+ };
+}