diff --git a/.changeset/signin-signup-autocomplete.md b/.changeset/signin-signup-autocomplete.md new file mode 100644 index 00000000000..f40a4920307 --- /dev/null +++ b/.changeset/signin-signup-autocomplete.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Sign-in and sign-up identifier fields now set standard `autocomplete` tokens so browsers and password managers can offer email, name, and phone. diff --git a/packages/ui/src/common/constants.ts b/packages/ui/src/common/constants.ts index 4b291268a1e..15ede217b38 100644 --- a/packages/ui/src/common/constants.ts +++ b/packages/ui/src/common/constants.ts @@ -6,6 +6,7 @@ import { localizationKeys } from '../localization/localizationKeys'; type FirstFactorConfig = { label: string | LocalizationKey; type: string; + autoComplete: 'email' | 'tel' | 'username'; placeholder: string | LocalizationKey; action?: string | LocalizationKey; validLastAuthenticationStrategies: ReadonlySet; @@ -15,6 +16,7 @@ const FirstFactorConfigs = Object.freeze({ label: localizationKeys('formFieldLabel__emailAddress_username'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_username'), type: 'text', + autoComplete: 'username', action: localizationKeys('signIn.start.actionLink__use_email_username'), validLastAuthenticationStrategies: new Set([ 'email_code', @@ -28,6 +30,7 @@ const FirstFactorConfigs = Object.freeze({ label: localizationKeys('formFieldLabel__emailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), type: 'email', + autoComplete: 'email', action: localizationKeys('signIn.start.actionLink__use_email'), validLastAuthenticationStrategies: new Set([ 'email_code', @@ -40,6 +43,7 @@ const FirstFactorConfigs = Object.freeze({ label: localizationKeys('formFieldLabel__phoneNumber'), placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), type: 'tel', + autoComplete: 'tel', action: localizationKeys('signIn.start.actionLink__use_phone'), validLastAuthenticationStrategies: new Set(['phone_code', 'password']), }, @@ -47,6 +51,7 @@ const FirstFactorConfigs = Object.freeze({ label: localizationKeys('formFieldLabel__username'), placeholder: localizationKeys('formFieldInputPlaceholder__username'), type: 'text', + autoComplete: 'username', action: localizationKeys('signIn.start.actionLink__use_username'), validLastAuthenticationStrategies: new Set(['username', 'password']), }, @@ -54,10 +59,11 @@ const FirstFactorConfigs = Object.freeze({ label: '', placeholder: '', type: 'text', + autoComplete: 'username', action: '', validLastAuthenticationStrategies: new Set(), }, -} as Record); +} satisfies Record); export type SignInStartIdentifier = 'email_address' | 'username' | 'phone_number' | 'email_address_username'; export const groupIdentifiers = (attributes: Attribute[]): SignInStartIdentifier[] => { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 4e3ae9bf6ff..d3e9d806bcb 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -671,7 +671,11 @@ function SignInStartInternal(): JSX.Element { onActionClicked={switchToNextIdentifier} {...identifierFieldProps} autoFocus={shouldAutofocus} - autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined} + autoComplete={ + isWebAuthnAutofillSupported + ? `${currentIdentifier.autoComplete} webauthn` + : currentIdentifier.autoComplete + } isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy} /> diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 1da263fbd58..fe66c6e12db 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -76,13 +76,44 @@ describe('SignInStart', () => { screen.getByText(/email address/i); }); + it('sets autocomplete=email on an email-only identifier field', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + }); + + render(, { wrapper }); + expect(screen.getByRole('textbox', { name: /email address/i })).toHaveAttribute('autocomplete', 'email'); + }); + + it('sets autocomplete=username when email and username are both enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUsername(); + }); + + render(, { wrapper }); + expect(screen.getByRole('textbox', { name: /email address or username/i })).toHaveAttribute( + 'autocomplete', + 'username', + ); + }); + + it('sets autocomplete=tel on a phone identifier field', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + }); + + render(, { wrapper }); + expect(screen.getByRole('textbox', { name: /phone number/i })).toHaveAttribute('autocomplete', 'tel'); + }); + it('enables login with username', async () => { const { wrapper } = await createFixtures(f => { f.withUsername(); }); render(, { wrapper }); - screen.getByText(/username/i); + expect(screen.getByRole('textbox', { name: /username/i })).toHaveAttribute('autocomplete', 'username'); }); it('enables login with phone number', async () => { @@ -152,6 +183,10 @@ describe('SignInStart', () => { flow: 'autofill', }); }); + expect(screen.getByRole('textbox', { name: /email address/i })).toHaveAttribute( + 'autocomplete', + 'email webauthn', + ); }); it('does not display related-origin errors from passkey autofill', async () => { diff --git a/packages/ui/src/components/SignUp/SignUpForm.tsx b/packages/ui/src/components/SignUp/SignUpForm.tsx index e36a979e464..649b1817c46 100644 --- a/packages/ui/src/components/SignUp/SignUpForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpForm.tsx @@ -59,6 +59,7 @@ export const SignUpForm = (props: SignUpFormProps) => { {...formState.firstName.props} isRequired={fields.firstName?.required} isOptional={!fields.firstName?.required} + autoComplete='given-name' /> )} {shouldShow('lastName') && ( @@ -66,6 +67,7 @@ export const SignUpForm = (props: SignUpFormProps) => { {...formState.lastName.props} isRequired={fields.lastName?.required} isOptional={!fields.lastName?.required} + autoComplete='family-name' /> )} @@ -76,6 +78,7 @@ export const SignUpForm = (props: SignUpFormProps) => { {...formState.username.props} isRequired={fields.username?.required} isOptional={!fields.username?.required} + autoComplete='username' /> )} @@ -86,6 +89,7 @@ export const SignUpForm = (props: SignUpFormProps) => { isRequired={fields.emailAddress?.required} isOptional={!fields.emailAddress?.required} isDisabled={fields.emailAddress?.disabled} + autoComplete='email' actionLabel={canToggleEmailPhone ? localizationKeys('signUp.start.actionLink__use_phone') : undefined} onActionClicked={canToggleEmailPhone ? () => handleEmailPhoneToggle('phoneNumber') : undefined} /> @@ -97,6 +101,7 @@ export const SignUpForm = (props: SignUpFormProps) => { {...formState.phoneNumber.props} isRequired={fields.phoneNumber?.required} isOptional={!fields.phoneNumber?.required} + autoComplete='tel' actionLabel={canToggleEmailPhone ? localizationKeys('signUp.start.actionLink__use_email') : undefined} onActionClicked={canToggleEmailPhone ? () => handleEmailPhoneToggle('emailAddress') : undefined} /> diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index eb5fb3f4739..f7adfc1457b 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -31,6 +31,21 @@ describe('SignUpStart', () => { screen.getByText('Email address'); }); + it('sets standard autocomplete tokens on sign-up fields', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withPhoneNumber({ required: true }); + f.withUsername({ required: true }); + f.withName({ required: true }); + }); + render(, { wrapper }); + expect(screen.getByRole('textbox', { name: 'First name' })).toHaveAttribute('autocomplete', 'given-name'); + expect(screen.getByRole('textbox', { name: 'Last name' })).toHaveAttribute('autocomplete', 'family-name'); + expect(screen.getByLabelText('Email address', { selector: 'input' })).toHaveAttribute('autocomplete', 'email'); + expect(screen.getByLabelText('Phone number', { selector: 'input' })).toHaveAttribute('autocomplete', 'tel'); + expect(screen.getByRole('textbox', { name: 'Username' })).toHaveAttribute('autocomplete', 'username'); + }); + it('enables sign up with phone number', async () => { const { wrapper } = await createFixtures(f => { f.withPhoneNumber({ required: true });