Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/phone-country-preferred-identifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': minor
'@clerk/ui': minor
---

Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value.
2 changes: 1 addition & 1 deletion packages/shared/src/internal/clerk-js/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const ERROR_CODES = {
USER_DEACTIVATED: 'user_deactivated',
} as const;

export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username'];
export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'phone_number_country_code'];
export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name'];

export const DEBOUNCE_MS = 350;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,7 @@ export type SignInInitialValues = {
emailAddress?: string;
phoneNumber?: string;
username?: string;
phoneNumberCountryCode?: string;
};

export type SignUpInitialValues = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PhoneCodeChannelData } from '@clerk/shared/types';
import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData';

import { Card } from '@/ui/elements/Card';
import { useCardState } from '@/ui/elements/contexts';
Expand All @@ -16,10 +17,11 @@ type SignUpAlternativePhoneCodePhoneNumberCardProps = {
phoneNumberFormState: FormControlState<any>;
onUseAnotherMethod: () => void;
phoneCodeProvider: PhoneCodeChannelData;
defaultCountryIso?: CountryIso;
};

export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternativePhoneCodePhoneNumberCardProps) => {
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props;
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider, defaultCountryIso } = props;
const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders();
const provider = phoneCodeProvider.name;
const channel = phoneCodeProvider.channel;
Expand Down Expand Up @@ -72,6 +74,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati
<Form.ControlRow elementId='phoneNumber'>
<Form.PhoneInput
{...phoneNumberFormState.props}
defaultCountryIso={defaultCountryIso}
label={localizationKeys('signIn.start.alternativePhoneCodeProvider.label', { provider })}
isRequired
isOptional={false}
Expand Down
55 changes: 47 additions & 8 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SignInCreateParams,
SignInResource,
} from '@clerk/shared/types';
import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

Expand All @@ -33,7 +34,7 @@ import {
withRedirectToSignInTask,
} from '../../common';
import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts';
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
import { Col, descriptors, Flow, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { useLoadingStatus } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
Expand Down Expand Up @@ -88,6 +89,7 @@ function SignInStartInternal(): JSX.Element {
const ctx = useSignInContext();
const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx;
const supportEmail = useSupportEmail();
const { parsedOptions } = useAppearance();
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
const identifierAttributes = useMemo<SignInStartIdentifier[]>(
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
Expand All @@ -103,13 +105,46 @@ function SignInStartInternal(): JSX.Element {
const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor);
const isWebSupported = isWebAuthnSupported();

const onlyPhoneNumberInitialValueExists =
!!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username);
const shouldStartWithPhoneNumberIdentifier =
onlyPhoneNumberInitialValueExists && identifierAttributes.includes('phone_number');
const [identifierAttribute, setIdentifierAttribute] = useState<SignInStartIdentifier>(
shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '',
);
const resolveInitialIdentifier = (): SignInStartIdentifier => {
const iv = ctx.initialValues;

const mapToIdentifierAttribute = (key: string): SignInStartIdentifier | undefined => {
if (key === 'phoneNumber') {
return identifierAttributes.includes('phone_number') ? 'phone_number' : undefined;
}
if (key === 'emailAddress') {
if (identifierAttributes.includes('email_address')) return 'email_address';
if (identifierAttributes.includes('email_address_username')) return 'email_address_username';
return undefined;
}
if (key === 'username') {
if (identifierAttributes.includes('email_address_username')) return 'email_address_username';
if (identifierAttributes.includes('username')) return 'username';
return undefined;
}
return undefined;
};

const filledValues = [
iv?.emailAddress && 'emailAddress',
iv?.phoneNumber && 'phoneNumber',
iv?.username && 'username',
].filter(Boolean) as string[];

if (filledValues.length === 1) {
const mapped = mapToIdentifierAttribute(filledValues[0]);
if (mapped) return mapped;
}

if (parsedOptions.preferredIdentifier) {
const mapped = mapToIdentifierAttribute(parsedOptions.preferredIdentifier);
if (mapped) return mapped;
}

return identifierAttributes[0] || '';
};
Comment on lines +144 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

SIGN_IN_FILE="$(fd -i 'SignInStart.tsx' packages/ui/src/components/SignIn | head -n1)"
COMMON_FILE="$(fd -i 'constants.ts' packages/ui/src/common | head -n1)"

echo "== SignInStartIdentifier definition =="
rg -n -C2 "export type SignInStartIdentifier" "$COMMON_FILE"

echo
echo "== Fallback returning empty string =="
rg -n -C2 "return identifierAttributes\\[0\\] \\|\\| ''" "$SIGN_IN_FILE"

Repository: clerk/javascript

Length of output: 616


🏁 Script executed:

#!/bin/bash
set -euo pipefail

SIGN_IN_FILE="$(fd -i 'SignInStart.tsx' packages/ui/src/components/SignIn | head -n1)"

echo "== Full function context around line 144 =="
sed -n '120,160p' "$SIGN_IN_FILE" | cat -n

echo
echo "== Search for function containing line 144 with return type =="
sed -n '100,150p' "$SIGN_IN_FILE" | cat -n

Repository: clerk/javascript

Length of output: 4320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

SIGN_IN_FILE="$(fd -i 'SignInStart.tsx' packages/ui/src/components/SignIn | head -n1)"

echo "== identifierAttributes initialization and usage =="
rg -n "identifierAttributes" "$SIGN_IN_FILE" | head -20

Repository: clerk/javascript

Length of output: 950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

SIGN_IN_FILE="$(fd -i 'SignInStart.tsx' packages/ui/src/components/SignIn | head -n1)"

echo "== identifierAttributes initialization (lines 94-110) =="
sed -n '94,110p' "$SIGN_IN_FILE" | cat -n

Repository: clerk/javascript

Length of output: 889


🏁 Script executed:

#!/bin/bash
set -euo pipefail

COMMON_FILE="$(fd -i 'constants.ts' packages/ui/src/common | head -n1)"

echo "== groupIdentifiers function definition =="
rg -n -A15 "export const groupIdentifiers" "$COMMON_FILE"

Repository: clerk/javascript

Length of output: 896


🏁 Script executed:

#!/bin/bash
set -euo pipefail

COMMON_FILE="$(fd -i 'constants.ts' packages/ui/src/common | head -n1)"

echo "== Attribute type definition =="
rg -n "export.*Attribute" "$COMMON_FILE" | head -20

echo
echo "== enabledFirstFactorIdentifiers related =="
rg -n "enabledFirstFactorIdentifiers" "$COMMON_FILE" | head -10

Repository: clerk/javascript

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

SIGN_IN_FILE="$(fd -i 'SignInStart.tsx' packages/ui/src/components/SignIn | head -n1)"

echo "== userSettings type and initialization =="
rg -n "userSettings" "$SIGN_IN_FILE" | head -15

echo
echo "== Search for Attribute type definition =="
rg -n "type Attribute\|interface Attribute" packages/ui/src

Repository: clerk/javascript

Length of output: 1235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for Attribute type in packages/ui =="
rg -n "type Attribute|interface Attribute" packages/ui/src --type ts --type tsx

echo
echo "== Search broader for Attribute definition =="
rg -n "Attribute" packages/ui/src/common/constants.ts | head -5

Repository: clerk/javascript

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

COMMON_FILE="$(fd -i 'constants.ts' packages/ui/src/common | head -n1)"

echo "== Full constants.ts file (first 100 lines) =="
head -100 "$COMMON_FILE" | cat -n

echo
echo "== Search for Attribute import/type =="
rg "Attribute" "$COMMON_FILE" | head -10

Repository: clerk/javascript

Length of output: 5092


Fix contract violation: resolveInitialIdentifier returns invalid empty string

Line 144-145 returns '', but the function's declared return type SignInStartIdentifier does not include empty string. This violates the type contract and creates invalid state when passed to useState<SignInStartIdentifier> at line 28.

Use 'email_address' as the fallback since it's always a valid identifier per the type definition:

Proposed fix
-    return identifierAttributes[0] || '';
+    const fallback = identifierAttributes[0];
+    if (fallback) {
+      return fallback;
+    }
+    return 'email_address';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return identifierAttributes[0] || '';
};
const fallback = identifierAttributes[0];
if (fallback) {
return fallback;
}
return 'email_address';
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/SignIn/SignInStart.tsx` around lines 144 - 145,
The function resolveInitialIdentifier currently may return an empty string which
violates the declared return type SignInStartIdentifier and can create invalid
state when used with useState<SignInStartIdentifier>; update
resolveInitialIdentifier to return 'email_address' as the fallback instead of ''
so it always returns a valid SignInStartIdentifier (this will fix the initial
state passed to useState<SignInStartIdentifier> in the SignInStart component).


const [identifierAttribute, setIdentifierAttribute] = useState<SignInStartIdentifier>(resolveInitialIdentifier);
const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false);

const organizationTicket = getClerkQueryParam('__clerk_ticket') || '';
Expand Down Expand Up @@ -596,6 +631,9 @@ function SignInStartInternal(): JSX.Element {
actionLabel={nextIdentifier?.action}
onActionClicked={switchToNextIdentifier}
{...identifierFieldProps}
defaultCountryIso={
ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined
}
autoFocus={shouldAutofocus}
autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined}
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
Expand Down Expand Up @@ -650,6 +688,7 @@ function SignInStartInternal(): JSX.Element {
phoneNumberFormState={phoneIdentifierField}
onUseAnotherMethod={onAlternativePhoneCodeUseAnotherMethod}
phoneCodeProvider={alternativePhoneCodeProvider}
defaultCountryIso={ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined}
/>
)}
</Flow.Part>
Expand Down
59 changes: 59 additions & 0 deletions packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,65 @@ describe('SignInStart', () => {
});
});

describe('preferredIdentifier (appearance option)', () => {
it('selects phone_number tab when set to phoneNumber', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
f.withPhoneNumber();
});
props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } });

render(<SignInStart />, { wrapper });
screen.getByText(/phone number/i);
});

it('selects email_address tab when set to emailAddress', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
f.withPhoneNumber();
});
props.setProps({ appearance: { options: { preferredIdentifier: 'emailAddress' } } });

render(<SignInStart />, { wrapper });
screen.getByText(/email address/i);
});

it('is ignored when identifier is not enabled', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
});
props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } });

render(<SignInStart />, { wrapper });
screen.getByText(/email address/i);
});

it('single filled initialValues value takes precedence', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
f.withPhoneNumber();
});
props.setProps({
initialValues: { phoneNumber: '+306911111111' },
appearance: { options: { preferredIdentifier: 'emailAddress' } },
});

render(<SignInStart />, { wrapper });
screen.getByDisplayValue(/691 1111111/i);
});

it('username maps to email_address_username when both enabled', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
f.withUsername();
});
props.setProps({ appearance: { options: { preferredIdentifier: 'username' } } });

render(<SignInStart />, { wrapper });
screen.getByText(/email address or username/i);
});
});

describe('Submitting form via instant password autofill', () => {
const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned'];
ERROR_CODES.forEach(code => {
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/SignUp/SignUpContinue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';
import { createUsernameError } from '@/ui/utils/usernameUtils';

import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts';
import { descriptors, Flex, Flow, localizationKeys, useLocalizations } from '../../customizables';
import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables';
import { useRouter } from '../../router';
import { SignUpForm } from './SignUpForm';
import type { ActiveIdentifier } from './signUpFormHelpers';
Expand Down Expand Up @@ -44,8 +44,9 @@ function SignUpContinueInternal() {
const isWithinSignInContext = !!React.useContext(SignInContext);
const isCombinedFlow = !!(_isCombinedFlow && !!isWithinSignInContext);
const isProgressiveSignUp = userSettings.signUp.progressive;
const { preferredIdentifier } = useAppearance().parsedOptions;
const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState<ActiveIdentifier>(
getInitialActiveIdentifier(attributes, userSettings.signUp.progressive),
getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, undefined, preferredIdentifier),
);
const ctx = useSignUpContext();

Expand Down
27 changes: 16 additions & 11 deletions packages/ui/src/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function SignUpStartInternal(): JSX.Element {
const clerk = useClerk();
const status = useLoadingStatus();
const signUp = useCoreSignUp();
const { showOptionalFields } = useAppearance().parsedOptions;
const { showOptionalFields, preferredIdentifier } = useAppearance().parsedOptions;
const { userSettings, authConfig } = useEnvironment();
const { navigate } = useRouter();
const { attributes } = userSettings;
Expand All @@ -47,16 +47,21 @@ function SignUpStartInternal(): JSX.Element {
const { afterSignUpUrl, signInUrl, unsafeMetadata, navigateOnSetActive } = ctx;
const isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext);
const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState<ActiveIdentifier>(() =>
getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, {
phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber,
emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress,
...(isCombinedFlow
? {
emailAddress: signUp.emailAddress,
phoneNumber: signUp.phoneNumber,
}
: {}),
}),
getInitialActiveIdentifier(
attributes,
userSettings.signUp.progressive,
{
phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber,
emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress,
...(isCombinedFlow
? {
emailAddress: signUp.emailAddress,
phoneNumber: signUp.phoneNumber,
}
: {}),
},
preferredIdentifier,
),
);
const { t, locale } = useLocalizations();
const initialValues = ctx.initialValues || {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1047,4 +1047,71 @@ describe('getInitialActiveIdentifier()', () => {
expect(getInitialActiveIdentifier(attributes, false)).toBe(null);
});
});

describe('respects preferredIdentifier on ties', () => {
it('returns phoneNumber in email-or-phone when preferredIdentifier is phoneNumber', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber');
});

it('returns emailAddress in email-or-phone when preferredIdentifier is emailAddress', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, false, undefined, 'emailAddress')).toBe('emailAddress');
});

it('returns phoneNumber when both are required and preferredIdentifier is phoneNumber', () => {
const attributes = {
email_address: createAttributeData('email_address', true, true, true),
phone_number: createAttributeData('phone_number', true, true, true),
};

expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber');
});

it('defaults to emailAddress in email-or-phone when no preferredIdentifier', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, false)).toBe('emailAddress');
});

it('ignores preferredIdentifier username since SignUp only has email/phone', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, false, undefined, 'username')).toBe('emailAddress');
});

it('initialValues take precedence over preferredIdentifier', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, false, { emailAddress: 'test@example.com' }, 'phoneNumber')).toBe(
'emailAddress',
);
});

it('returns phoneNumber in progressive signup email-or-phone with preferredIdentifier', () => {
const attributes = {
email_address: createAttributeData('email_address', true, false, true),
phone_number: createAttributeData('phone_number', true, false, true),
};

expect(getInitialActiveIdentifier(attributes, true, undefined, 'phoneNumber')).toBe('phoneNumber');
});
});
});
20 changes: 16 additions & 4 deletions packages/ui/src/components/SignUp/signUpFormHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const getInitialActiveIdentifier = (
attributes: Partial<Attributes>,
isProgressiveSignUp: boolean,
initialValues?: { phoneNumber?: string | null; emailAddress?: string | null },
preferredIdentifier?: 'emailAddress' | 'phoneNumber' | 'username',
): ActiveIdentifier => {
if (initialValues?.emailAddress) {
return 'emailAddress';
Expand All @@ -115,18 +116,29 @@ export const getInitialActiveIdentifier = (
return 'phoneNumber';
}

const preferred =
preferredIdentifier === 'emailAddress' || preferredIdentifier === 'phoneNumber' ? preferredIdentifier : undefined;

if (emailOrPhone(attributes, isProgressiveSignUp)) {
// If we are in the case of Email OR Phone, email takes priority
return 'emailAddress';
return preferred ?? 'emailAddress';
}

const { email_address, phone_number } = attributes;

if (email_address?.enabled && isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor) {
const emailMatches =
email_address?.enabled && (isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor);
const phoneMatches =
phone_number?.enabled && (isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor);

if (emailMatches && phoneMatches) {
return preferred ?? 'emailAddress';
}

if (emailMatches) {
return 'emailAddress';
}

if (phone_number?.enabled && isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor) {
if (phoneMatches) {
return 'phoneNumber';
}

Expand Down
Loading
Loading