import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode'; import { inertProps } from '@clerk/shared/inert'; import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import type { ClerkAPIError, PhoneCodeChannel, PhoneCodeChannelData, SignInCreateParams, SignInResource, } from '@clerk/shared/types'; import { isWebAuthnAutofillSupported } from '@clerk/shared/webauthn'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import { LoadingCard } from '@/ui/elements/LoadingCard'; import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer'; import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked'; import { handleError } from '@/ui/utils/errorHandler'; import { isMobileDevice } from '@/ui/utils/isMobileDevice'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import type { SignInStartIdentifier } from '../../common'; import { ActionBlockedCard, getIdentifierControlDisplayValues, groupIdentifiers, withRedirectToAfterSignIn, withRedirectToSignInTask, } from '../../common'; import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useIsWebAuthnSupported, useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; import { hasOnlyEnterpriseSSOFirstFactors, shouldHandOffToEnterpriseConnection, shouldHandOffUnidentifiedToEnterpriseConnection, } from './enterpriseSSOFactors'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; import { isProtectCheckRequiredError, navigateOnSignInProtectGate } from './handleProtectCheck'; import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM, useHandleAuthenticateWithPasskey } from './shared'; import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard'; import { SignInSocialButtons } from './SignInSocialButtons'; import { getPreferredAlternativePhoneChannel, getPreferredAlternativePhoneChannelForCombinedFlow, getSignUpAttributeFromIdentifier, } from './utils'; const useAutoFillPasskey = () => { const [isSupported, setIsSupported] = useState(false); const { navigate } = useRouter(); const onSecondFactor = () => navigate('factor-two'); const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check'); const { userSettings } = useEnvironment(); const { passkeySettings, attributes } = userSettings; // @ts-expect-error - This is not a public API const { __internal_isWebAuthnAutofillSupported } = useClerk(); useEffect(() => { async function runAutofillPasskey() { const _isSupported = await (__internal_isWebAuthnAutofillSupported ?? isWebAuthnAutofillSupported)(); setIsSupported(_isSupported); if (!_isSupported) { return; } await authenticateWithPasskey({ flow: 'autofill' }); } if (passkeySettings.allow_autofill && attributes.passkey?.enabled) { runAutofillPasskey(); } }, []); return { isWebAuthnAutofillSupported: isSupported, }; }; function SignInStartInternal(): JSX.Element { const card = useCardState(); const clerk = useClerk(); const status = useLoadingStatus(); const { userSettings, authConfig } = useEnvironment(); const signIn = useCoreSignIn(); const { navigate } = useRouter(); const ctx = useSignInContext(); const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, signUpIfMissingEnabled, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); const totalEnabledAuthMethods = useTotalEnabledAuthMethods(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), [userSettings.enabledFirstFactorIdentifiers], ); const alternativePhoneCodeChannels = userSettings.alternativePhoneCodeChannels; /** * Passkeys */ const { isWebAuthnAutofillSupported } = useAutoFillPasskey(); const onSecondFactor = () => navigate('factor-two'); const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check'); const isWebSupported = useIsWebAuthnSupported(); const onlyPhoneNumberInitialValueExists = !!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username); const shouldStartWithPhoneNumberIdentifier = onlyPhoneNumberInitialValueExists && identifierAttributes.includes('phone_number'); const [identifierAttribute, setIdentifierAttribute] = useState( shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '', ); const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; const clerkStatus = getClerkQueryParam('__clerk_status') || ''; const standardFormAttributes = userSettings.enabledFirstFactorIdentifiers; const web3FirstFactors = userSettings.web3FirstFactors; const authenticatableSocialStrategies = userSettings.authenticatableSocialStrategies; const passwordBasedInstance = userSettings.instanceIsPasswordBased; const { currentIdentifier, nextIdentifier } = getIdentifierControlDisplayValues( identifierAttributes, identifierAttribute, ); const instantPasswordField = useFormControl('password', '', { type: 'password', label: localizationKeys('formFieldLabel__password'), placeholder: localizationKeys('formFieldInputPlaceholder__password') as any, }); const [alternativePhoneCodeProvider, setAlternativePhoneCodeProvider] = useState(null); const showAlternativePhoneCodeProviders = userSettings.alternativePhoneCodeChannels.length > 0; const onAlternativePhoneCodeUseAnotherMethod = () => { setAlternativePhoneCodeProvider(null); }; const onAlternativePhoneCodeProviderClick = (phoneCodeChannel: PhoneCodeChannel) => { const provider: PhoneCodeChannelData | null = getAlternativePhoneCodeProviderData(phoneCodeChannel) || null; setAlternativePhoneCodeProvider(provider); }; const ctxInitialValues = ctx.initialValues || {}; const initialValues: Record = useMemo( () => ({ email_address: ctxInitialValues.emailAddress, email_address_username: ctxInitialValues.emailAddress || ctxInitialValues.username, username: ctxInitialValues.username, phone_number: ctxInitialValues.phoneNumber, }), [ctx.initialValues], ); const hasSocialOrWeb3Buttons = !!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length; const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons); // When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the // rest of the card (see the descriptors.main column below). const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false); const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', { ...currentIdentifier, isRequired: true, transformer: value => value.trim(), }); const phoneIdentifierField = useFormControl('identifier', initialValues['phone_number'] || '', { ...currentIdentifier, isRequired: true, }); const identifierField = identifierAttribute === 'phone_number' ? phoneIdentifierField : textIdentifierField; const switchToNextIdentifier = () => { setIdentifierAttribute( i => identifierAttributes[(identifierAttributes.indexOf(i) + 1) % identifierAttributes.length], ); setShouldAutofocus(true); setHasSwitchedByAutofill(false); }; const handlePhoneNumberPaste = (value: string) => { textIdentifierField.setValue(initialValues[identifierAttribute] || ''); phoneIdentifierField.setValue(value); setIdentifierAttribute('phone_number'); setShouldAutofocus(true); }; // switch to the phone input (if available) if a "+" is entered // (either by the browser or the user) // this does not work in chrome as it does not fire the change event and the value is // not available via js useLayoutEffect(() => { if ( identifierField.value.startsWith('+') && identifierAttributes.includes('phone_number') && identifierAttribute !== 'phone_number' && !hasSwitchedByAutofill ) { handlePhoneNumberPaste(identifierField.value); // do not switch automatically on subsequent autofills // by the browser to avoid a switch loop setHasSwitchedByAutofill(true); } }, [identifierField.value, identifierAttributes]); const buildSignInParams = (fields: Array>): SignInCreateParams => { const hasPassword = fields.some(f => f.name === 'password' && !!f.value); /** * FAPI will return an error when password is submitted but the user's email matches requires enterprise sso authentication. * We need to strip password from the create request, and reconstruct it later. */ if (!hasPassword || userSettings.enterpriseSSO.enabled) { fields = fields.filter(f => f.name !== 'password'); } return { ...buildRequest(fields), ...(hasPassword && !userSettings.enterpriseSSO.enabled && { strategy: 'password' }), } as SignInCreateParams; }; const safePasswordSignInForEnterpriseSSOInstance = ( signInCreatePromise: Promise, fields: Array>, ) => { return signInCreatePromise.then(signInResource => { if (!userSettings.enterpriseSSO.enabled) { return signInResource; } /** * For instances with Enterprise SSO enabled, perform sign in with password only when it is allowed for the identified user. */ const passwordField = fields.find(f => f.name === 'password')?.value; if (!passwordField || signInResource.supportedFirstFactors?.some(ff => ff.strategy === 'enterprise_sso')) { return signInResource; } return signInResource.attemptFirstFactor({ strategy: 'password', password: passwordField }); }); }; const signInWithFields = async ( fields: Array>, options?: { resetPasswordIntent?: boolean }, ) => { // If the user has already selected an alternative phone code provider, we use that. const preferredAlternativePhoneChannel = alternativePhoneCodeProvider?.channel || getPreferredAlternativePhoneChannel(fields, authConfig.preferredChannels, 'identifier'); if (preferredAlternativePhoneChannel) { // We need to send the alternative phone code provider channel in the sign in request // together with the phone_code strategy, in order for FAPI to create a Verification upon this first request. const noop = () => {}; fields.push({ id: 'strategy', value: 'phone_code', clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); fields.push({ id: 'channel', value: preferredAlternativePhoneChannel, clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); } try { // On top of the context-level preconditions, sign-up-if-missing only // supports identifiers that can be verified out-of-band. const hasPassword = fields.some(f => f.name === 'password' && !!f.value); const signUpAttribute = getSignUpAttributeFromIdentifier(identifierField); const shouldSignUpIfMissing = signUpIfMissingEnabled && signUpAttribute !== 'username' && !hasPassword; const res = await safePasswordSignInForEnterpriseSSOInstance( signIn.create({ ...buildSignInParams(fields), ...(shouldSignUpIfMissing && { signUpIfMissing: true }), }), fields, ); if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) { return; } switch (res.status) { case 'needs_identifier': // Check if we need to initiate an enterprise sso flow if (shouldHandOffUnidentifiedToEnterpriseConnection(res)) { await authenticateWithEnterpriseSSO(); } break; case 'needs_first_factor': { if (!shouldHandOffToEnterpriseConnection(res)) { if (options?.resetPasswordIntent) { return navigate('factor-one', { searchParams: new URLSearchParams({ [SIGN_IN_RESET_PASSWORD_INTENT_PARAM]: 'true' }), }); } return navigate('factor-one'); } // Awaited so a failed hand-off reaches the catch below instead of escaping this try. return await authenticateWithEnterpriseSSO(); } case 'needs_second_factor': return navigate('factor-two'); case 'needs_client_trust': return navigate('client-trust'); case 'complete': return clerk.setActive({ session: res.createdSessionId, navigate: async ({ session, decorateUrl }) => { await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); return; } } } catch (e: any) { return attemptToRecoverFromSignInError(e); } }; const authenticateWithEnterpriseSSO = async () => { const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; try { await signIn.authenticateWithRedirect({ strategy: 'enterprise_sso', redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt, continueSignIn: true, }); } catch (err) { // Preparing the hand-off can itself raise a challenge. No redirect was issued and the sign-in // is sitting on the gate instead. Handled here because the callers' recovery path drops // errors that didn't come from the API. if (isProtectCheckRequiredError(err) && navigateOnSignInProtectGate(signIn, navigate, 'protect-check')) { return; } throw err; } }; const attemptToRecoverFromSignInError = async (e: any) => { if (!e.errors) { return; } const instantPasswordError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === ERROR_CODES.INVALID_STRATEGY_FOR_USER || e.code === ERROR_CODES.FORM_PASSWORD_INCORRECT || e.code === ERROR_CODES.FORM_PASSWORD_PWNED, ); const sessionAlreadyExistsError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS, ); const alreadySignedInError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === 'identifier_already_signed_in', ); const accountDoesNotExistError: ClerkAPIError = e.errors.find( (e: ClerkAPIError) => e.code === ERROR_CODES.INVITATION_ACCOUNT_NOT_EXISTS || e.code === ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND, ); if (instantPasswordError) { await signInWithFields([identifierField]); } else if (sessionAlreadyExistsError) { await clerk.setActive({ session: clerk.client.lastActiveSessionId, navigate: async ({ session, decorateUrl }) => { await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); } else if (alreadySignedInError) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const sid = alreadySignedInError.meta!.sessionId!; await clerk.setActive({ session: sid, navigate: async ({ session, decorateUrl }) => { await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); } else if (isCombinedFlow && accountDoesNotExistError) { const attribute = getSignUpAttributeFromIdentifier(identifierField); if (userSettings.signUp.mode === SIGN_UP_MODES.WAITLIST) { const waitlistUrl = clerk.buildWaitlistUrl( attribute === 'emailAddress' ? { initialValues: { [attribute]: identifierField.value, }, } : {}, ); return navigate(waitlistUrl); } const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignUpUrl || '/'; return handleCombinedFlowTransfer({ afterSignUpUrl: ctx.afterSignUpUrl || '/', clerk, handleError: e => handleError(e, [identifierField, instantPasswordField], card.setError), identifierAttribute: attribute, identifierValue: identifierField.value, navigate, organizationTicket, signUpMode: userSettings.signUp.mode, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt, navigateOnSetActive, passwordEnabled: userSettings.attributes.password?.required ?? false, alternativePhoneCodeChannel: alternativePhoneCodeProvider?.channel || getPreferredAlternativePhoneChannelForCombinedFlow( authConfig.preferredChannels, attribute, identifierField.value, ), unsafeMetadata: ctx.unsafeMetadata, }); } else { handleError(e, [identifierField, instantPasswordField], card.setError); } }; useEffect(() => { if (!organizationTicket) { return; } if (clerkStatus === 'sign_up') { const paramsToForward = new URLSearchParams(); if (organizationTicket) { paramsToForward.set('__clerk_ticket', organizationTicket); } // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to // signUpUrl triggers a full page reload when used with the hash router. void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); return; } status.setLoading(); card.setLoading(); signIn .create({ strategy: 'ticket', ticket: organizationTicket, }) .then(res => { if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) { return; } switch (res.status) { case 'needs_first_factor': { if (!shouldHandOffToEnterpriseConnection(res)) { return navigate('factor-one'); } return authenticateWithEnterpriseSSO(); } case 'needs_second_factor': return navigate('factor-two'); case 'needs_client_trust': return navigate('client-trust'); case 'complete': removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ session: res.createdSessionId, navigate: async ({ session, decorateUrl }) => { await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); return; } } }) .catch(err => { return attemptToRecoverFromSignInError(err); }) .finally(() => { // Keep the card in loading state during SSO redirect to prevent UI flicker // This is necessary because there's a brief delay between initiating the SSO flow // and the actual redirect to the external Identity Provider const isRedirectingToSSOProvider = !!hasOnlyEnterpriseSSOFirstFactors(signIn); if (isRedirectingToSSOProvider) { return; } status.setIdle(); card.setIdle(); }); }, []); useEffect(() => { async function handleOauthError() { const defaultErrorHandler = () => { // Error from server may be too much information for the end user, so set a generic error card.setError('Unable to complete action at this time. If the problem persists please contact support.'); }; const error = signIn?.firstFactorVerification?.error; if (error) { switch (error.code) { case ERROR_CODES.NOT_ALLOWED_TO_SIGN_UP: case ERROR_CODES.OAUTH_ACCESS_DENIED: case ERROR_CODES.NOT_ALLOWED_ACCESS: case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING: case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML: case ERROR_CODES.USER_LOCKED: case ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND: case ERROR_CODES.SIGN_UP_MODE_RESTRICTED: case ERROR_CODES.SIGN_UP_MODE_RESTRICTED_WAITLIST: case ERROR_CODES.ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: case ERROR_CODES.ENTERPRISE_SSO_EMAIL_ADDRESS_DOMAIN_MISMATCH: case ERROR_CODES.ENTERPRISE_SSO_HOSTED_DOMAIN_MISMATCH: case ERROR_CODES.SAML_EMAIL_ADDRESS_DOMAIN_MISMATCH: case ERROR_CODES.ORGANIZATION_MEMBERSHIP_QUOTA_EXCEEDED_FOR_SSO: case ERROR_CODES.CAPTCHA_INVALID: case ERROR_CODES.FRAUD_DEVICE_BLOCKED: case ERROR_CODES.FRAUD_ACTION_BLOCKED: case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: case ERROR_CODES.USER_BANNED: case ERROR_CODES.USER_DEACTIVATED: card.setError(error); break; default: defaultErrorHandler(); } // TODO: This is a workaround in order to reset the sign in attempt // so that the oauth error does not persist on full page reloads. void (await signIn.create({})); } } void handleOauthError(); }, []); const handleFirstPartySubmit = async (e: React.FormEvent) => { e.preventDefault(); return signInWithFields([identifierField, instantPasswordField]); }; const handleForgotPasswordClick: React.MouseEventHandler = e => { e.preventDefault(); // Surface the same native required-field validation as the Continue button // when the identifier is missing const form = e.currentTarget.closest('form'); if (form && !form.reportValidity()) { return; } void signInWithFields([identifierField], { resetPasswordIntent: true }); }; const DynamicField = useMemo(() => { const components = { tel: Form.PhoneInput, password: Form.PasswordInput, text: Form.PlainInput, email: Form.PlainInput, }; return components[identifierField.type as keyof typeof components]; }, [identifierField.type]); if (status.isLoading || clerkStatus === 'sign_up') { // clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of // rendering the sign in flow. return ; } // @ts-expect-error `action` is not typed const { action, validLastAuthenticationStrategies, ...identifierFieldProps } = identifierField.props; const lastAuthenticationStrategy = clerk.client?.lastAuthenticationStrategy; const isIdentifierLastAuthenticationStrategy = lastAuthenticationStrategy && totalEnabledAuthMethods > 1 ? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy) : false; const blockedDetails = actionBlockedDetailsFrom(card.rawError); if (blockedDetails) { return ; } return ( {!alternativePhoneCodeProvider ? ( {card.error} {/*TODO: extract main in its own component */} {hasSocialOrWeb3Buttons && ( )} {standardFormAttributes.length ? ( ) : null} {/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */} {userSettings.attributes.passkey?.enabled && userSettings.passkeySettings.show_sign_in_button && isWebSupported && ( authenticateWithPasskey({ flow: 'discoverable' })} /> )} {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && ( )} {userSettings.signUp.mode === SIGN_UP_MODES.WAITLIST && ( )} ) : ( )} ); } const InstantPasswordRow = ({ field, onForgotPasswordClick, }: { field?: FormControlState<'password'>; onForgotPasswordClick?: React.MouseEventHandler; }) => { const [autofilled, setAutofilled] = useState(false); const ref = useRef(null); const show = !!(autofilled || field?.value); // show password if it's autofilled by the browser useLayoutEffect(() => { const intervalId = setInterval(() => { if (ref?.current) { const autofilled = window.getComputedStyle(ref.current, ':autofill').animationName === 'onAutoFillStart' || // https://github.com/facebook/react/issues/1159#issuecomment-1025423604 !!ref.current?.matches('*:-webkit-autofill'); if (autofilled) { setAutofilled(autofilled); clearInterval(intervalId); } } }, 500); return () => { clearInterval(intervalId); }; }, []); useEffect(() => { //if the field receives a value, we default to normal behaviour if (field?.value && field.value !== '') { setAutofilled(false); } }, [field?.value]); if (!field) { return null; } return ( ); }; export const SignInStart = withRedirectToSignInTask( withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)), );