Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion packages/ui/src/customizables/parseAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {

export type ParsedElements = Elements[];
export type ParsedInternalTheme = InternalTheme;
export type ParsedOptions = Required<Options>;
export type ParsedOptions = Required<Omit<Options, 'preferredIdentifier'>> & Pick<Options, 'preferredIdentifier'>;
export type ParsedCaptcha = Required<CaptchaAppearanceOptions>;

type PublicAppearanceTopLevelKey = Exclude<
Expand Down Expand Up @@ -51,6 +51,7 @@ const defaultOptions: ParsedOptions = {
shimmer: true,
animations: true,
unsafe_disableDevelopmentModeWarnings: false,
preferredIdentifier: undefined,
};

const defaultCaptchaOptions: ParsedCaptcha = {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/elements/FieldControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { FormFeedbackProps } from './FormControl';
import { FormFeedback } from './FormControl';
import { InputGroup } from './InputGroup';
import { PasswordInput } from './PasswordInput';
import type { CountryIso } from './PhoneInput/countryCodeData';
import { PhoneInput } from './PhoneInput';
import { RadioItem, RadioLabel } from './RadioGroup';

Expand Down Expand Up @@ -168,7 +169,7 @@ const FieldFeedback = (props: Pick<FormFeedbackProps, 'elementDescriptors' | 'ce
);
};

const PhoneInputElement = forwardRef<HTMLInputElement>((_, ref) => {
const PhoneInputElement = forwardRef<HTMLInputElement, { defaultCountryIso?: CountryIso }>((props, ref) => {
const { t } = useLocalizations();
const formField = useFormField();
const { placeholder, ...inputProps } = sanitizeInputProps(formField);
Expand All @@ -179,6 +180,7 @@ const PhoneInputElement = forwardRef<HTMLInputElement>((_, ref) => {
elementDescriptor={descriptors.formFieldInput}
elementId={descriptors.formFieldInput.setId(formField.fieldId)}
{...inputProps}
defaultCountryIso={props.defaultCountryIso}
feedbackType={formField.feedbackType}
placeholder={t(placeholder)}
/>
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/elements/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContextAndHook } from '@clerk/shared/react';
import type { FieldId } from '@clerk/shared/types';
import type { CountryIso } from './PhoneInput/countryCodeData';
import type { PropsWithChildren } from 'react';
import React, { forwardRef, useState } from 'react';

Expand Down Expand Up @@ -196,10 +197,11 @@ const PasswordInput = forwardRef<HTMLInputElement, CommonInputProps>((props, ref
);
});

const PhoneInput = (props: CommonInputProps) => {
const PhoneInput = (props: CommonInputProps & { defaultCountryIso?: CountryIso }) => {
const { defaultCountryIso, ...rest } = props;
return (
<CommonInputWrapper {...props}>
<Field.PhoneInput />
<CommonInputWrapper {...rest}>
<Field.PhoneInput defaultCountryIso={defaultCountryIso} />
</CommonInputWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,62 @@ describe('useFormattedPhoneNumber', () => {

unmount();
});

it('defaultCountryIso is used when no phone number and no locationBasedCountryIso', () => {
const { result } = renderHook(() =>
useFormattedPhoneNumber({
initPhoneWithCode: '',
defaultCountryIso: 'gr',
locationBasedCountryIso: undefined,
}),
);

expect(result.current.iso).toBe('gr');
});

it('defaultCountryIso takes precedence over locationBasedCountryIso', () => {
const { result } = renderHook(() =>
useFormattedPhoneNumber({
initPhoneWithCode: '',
defaultCountryIso: 'de',
locationBasedCountryIso: 'fr',
}),
);

expect(result.current.iso).toBe('de');
});

it('parsed phone number takes precedence over defaultCountryIso', () => {
const { result } = renderHook(() =>
useFormattedPhoneNumber({
initPhoneWithCode: '+71111111111',
defaultCountryIso: 'gr',
}),
);

expect(result.current.iso).toBe('ru');
});

it('invalid defaultCountryIso falls back to locationBasedCountryIso', () => {
const { result } = renderHook(() =>
useFormattedPhoneNumber({
initPhoneWithCode: '',
defaultCountryIso: 'xx' as any,
locationBasedCountryIso: 'fr',
}),
);

expect(result.current.iso).toBe('fr');
});

it('invalid defaultCountryIso with no locationBasedCountryIso falls back to us', () => {
const { result } = renderHook(() =>
useFormattedPhoneNumber({
initPhoneWithCode: '',
defaultCountryIso: 'zz' as any,
}),
);

expect(result.current.iso).toBe('us');
});
});
16 changes: 14 additions & 2 deletions packages/ui/src/elements/PhoneInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,27 @@ const createSelectOption = (country: CountryEntry) => {

const countryOptions = [...IsoToCountryMap.values()].map(createSelectOption);

type PhoneInputProps = PropsOfComponent<typeof Input> & { locationBasedCountryIso?: CountryIso };
type PhoneInputProps = PropsOfComponent<typeof Input> & {
locationBasedCountryIso?: CountryIso;
defaultCountryIso?: CountryIso;
};

const PhoneInputBase = forwardRef<HTMLInputElement, PhoneInputProps & { feedbackType?: FeedbackType }>((props, ref) => {
const { onChange: onChangeProp, value, locationBasedCountryIso, feedbackType, sx, ...rest } = props;
const {
onChange: onChangeProp,
value,
locationBasedCountryIso,
defaultCountryIso,
feedbackType,
sx,
...rest
} = props;
const phoneInputRef = useRef<HTMLInputElement>(null);
const phoneInputBox = useRef<HTMLDivElement>(null);
const { setNumber, setIso, setNumberAndIso, numberWithCode, iso, formattedNumber } = useFormattedPhoneNumber({
initPhoneWithCode: value as string,
locationBasedCountryIso,
defaultCountryIso,
});

const callOnChangeProp = () => {
Expand Down
Loading
Loading