import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode'; import { isClerkAPIResponseError } from '@clerk/shared/error'; import { inertProps } from '@clerk/shared/inert'; import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { getClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types'; import React from 'react'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; 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 { createPasswordError } from '@/ui/utils/passwordUtils'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import { createUsernameError } from '@/ui/utils/usernameUtils'; import { ActionBlockedCard, withRedirectToAfterSignUp, withRedirectToSignUpTask } from '../../common'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useLoadingStatus } from '../../hooks'; import { useRouter } from '../../router'; import { getPreferredAlternativePhoneChannel } from '../SignIn/utils'; import { SignUpForm } from './SignUpForm'; import type { ActiveIdentifier } from './signUpFormHelpers'; import { determineActiveFields, emailOrPhone, getInitialActiveIdentifier, showFormFields } from './signUpFormHelpers'; import { SignUpRestrictedAccess } from './SignUpRestrictedAccess'; import { SignUpSocialButtons } from './SignUpSocialButtons'; import { SignUpStartAlternativePhoneCodePhoneNumberCard } from './SignUpStartAlternativePhoneCodePhoneNumberCard'; import { useCompleteSignUpFlow } from './useCompleteSignUpFlow'; function SignUpStartInternal(): JSX.Element { const card = useCardState(); const clerk = useClerk(); const status = useLoadingStatus(); const signUp = useCoreSignUp(); const { showOptionalFields } = useAppearance().parsedOptions; const { userSettings, authConfig } = useEnvironment(); const { navigate } = useRouter(); const { attributes } = userSettings; const ctx = useSignUpContext(); const isWithinSignInContext = !!React.useContext(SignInContext); const { signInUrl, unsafeMetadata } = ctx; const completeSignUpFlow = useCompleteSignUpFlow(); const isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState(() => 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, } : {}), }), ); const { t, locale } = useLocalizations(); const initialValues = ctx.initialValues || {}; const [alternativePhoneCodeProvider, setAlternativePhoneCodeProvider] = React.useState( null, ); const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false); // 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] = React.useState(false); const { userSettings: { passwordSettings, usernameSettings }, } = useEnvironment(); const { mode } = userSettings.signUp; const formState = { firstName: useFormControl('firstName', signUp.firstName || initialValues.firstName || '', { type: 'text', label: localizationKeys('formFieldLabel__firstName'), placeholder: localizationKeys('formFieldInputPlaceholder__firstName'), }), lastName: useFormControl('lastName', signUp.lastName || initialValues.lastName || '', { type: 'text', label: localizationKeys('formFieldLabel__lastName'), placeholder: localizationKeys('formFieldInputPlaceholder__lastName'), }), emailAddress: useFormControl('emailAddress', signUp.emailAddress || initialValues.emailAddress || '', { type: 'email', label: localizationKeys('formFieldLabel__emailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), }), username: useFormControl('username', signUp.username || initialValues.username || '', { type: 'text', label: localizationKeys('formFieldLabel__username'), placeholder: localizationKeys('formFieldInputPlaceholder__username'), transformer: value => value.trim(), buildErrorMessage: errors => createUsernameError(errors, { t, locale, usernameSettings }), }), phoneNumber: useFormControl('phoneNumber', signUp.phoneNumber || initialValues.phoneNumber || '', { type: 'tel', label: localizationKeys('formFieldLabel__phoneNumber'), placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), }), legalAccepted: useFormControl('legalAccepted', '', { type: 'checkbox', label: '', defaultChecked: false, isRequired: userSettings.signUp.legal_consent_enabled || false, }), password: useFormControl('password', '', { type: 'password', label: localizationKeys('formFieldLabel__password'), placeholder: localizationKeys('formFieldInputPlaceholder__signUpPassword'), validatePassword: true, buildErrorMessage: errors => createPasswordError(errors, { t, locale, passwordSettings }), }), ticket: useFormControl( 'ticket', getClerkQueryParam('__clerk_ticket') || getClerkQueryParam('__clerk_invitation_token') || '', ), } as const; const hasTicket = !!formState.ticket.value; const hasExistingSignUpWithTicket = !!( signUp.id && signUp.status !== null && (getClerkQueryParam('__clerk_ticket') || getClerkQueryParam('__clerk_invitation_token')) ); const hasEmail = !!formState.emailAddress.value; const isProgressiveSignUp = userSettings.signUp.progressive; const isLegalConsentEnabled = userSettings.signUp.legal_consent_enabled; const fields = determineActiveFields({ attributes, hasTicket: hasTicket || hasExistingSignUpWithTicket, hasEmail, activeCommIdentifierType, isProgressiveSignUp, legalConsentRequired: isLegalConsentEnabled, }); const handleTokenFlow = () => { if (!formState.ticket.value) { return; } status.setLoading(); card.setLoading(); signUp .create({ strategy: 'ticket', ticket: formState.ticket.value, unsafeMetadata }) .then(signUp => { formState.emailAddress.setValue(signUp.emailAddress || ''); // In case we are in a Ticket flow and the sign up is not complete yet, update the state // to render properly the SignUp form with other available options to complete it (e.g. OAuth) if (signUp.status === 'missing_requirements') { setMissingRequirementsWithTicket(true); } return completeSignUpFlow({ signUp, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', protectCheckPath: 'protect-check', continuePath: 'continue', }); }) .catch(err => { /* Clear ticket values when an error occurs in the initial sign up attempt */ formState.ticket.setValue(''); handleError(err, [], card.setError); }) .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 = signUp.missingFields.some(mf => mf === 'enterprise_sso'); if (isRedirectingToSSOProvider) { return; } status.setIdle(); card.setIdle(); }); }; React.useLayoutEffect(() => { void handleTokenFlow(); }, []); React.useEffect(() => { async function handleOauthError() { const error = signUp.verifications.externalAccount.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.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: // 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.'); } // TODO: This is a hack to reset the sign in attempt so that the oauth error // does not persist on full page reloads. // We will revise this strategy as part of the Clerk DX epic. void (await signUp.create({})); } } void handleOauthError(); }, []); const handleChangeActive = (type: ActiveIdentifier) => { if (!emailOrPhone(attributes, isProgressiveSignUp)) { return; } setActiveCommIdentifierType(type); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); type FormStateKey = keyof typeof formState; const fieldsToSubmit = Object.entries(fields).reduce((acc, [k, v]) => { acc.push(...(v && formState[k as FormStateKey] ? [formState[k as FormStateKey]] : [])); return acc; }, [] as Array); if (unsafeMetadata) { const noop = () => {}; fieldsToSubmit.push({ id: 'unsafeMetadata', value: unsafeMetadata, clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); } if (fields.ticket || hasExistingSignUpWithTicket) { const noop = () => {}; // fieldsToSubmit: Constructing a fake fields object for strategy. fieldsToSubmit.push({ id: 'strategy', value: 'ticket', clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); // Get ticket value from query params if it exists if (!formState.ticket.value && hasExistingSignUpWithTicket) { const ticketValue = getClerkQueryParam('__clerk_ticket') || getClerkQueryParam('__clerk_invitation_token'); if (ticketValue) { fieldsToSubmit.push({ id: 'ticket', value: ticketValue, clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); } } } // If the user has already selected an alternative phone code provider, we use that. const preferredAlternativePhoneChannel = alternativePhoneCodeProvider?.channel || getPreferredAlternativePhoneChannel(fieldsToSubmit, authConfig.preferredChannels, 'phoneNumber'); if (preferredAlternativePhoneChannel) { // We need to send the alternative phone code provider channel in the sign up request // together with the phone_code strategy, in order for FAPI to create a Verification upon this first request. const noop = () => {}; fieldsToSubmit.push({ id: 'strategy', value: 'phone_code', clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); fieldsToSubmit.push({ id: 'channel', value: preferredAlternativePhoneChannel, clearFeedback: noop, setValue: noop, onChange: noop, setError: noop, } as any); } // In case of emailOrPhone (both email & phone are optional) and neither of them is provided, // add both to the submitted fields to trigger and render an error for both respective inputs const emailAddressProvided = !!(fieldsToSubmit.find(f => f.id === 'emailAddress')?.value || ''); const phoneNumberProvided = !!(fieldsToSubmit.find(f => f.id === 'phoneNumber')?.value || ''); if (!emailAddressProvided && !phoneNumberProvided && emailOrPhone(attributes, isProgressiveSignUp)) { fieldsToSubmit.push(formState.emailAddress); fieldsToSubmit.push(formState.phoneNumber); } card.setLoading(); card.setError(undefined); let signUpAttempt: Promise; if (!fields.ticket && !hasExistingSignUpWithTicket) { signUpAttempt = signUp.create(buildRequest(fieldsToSubmit)); } else { signUpAttempt = signUp.upsert(buildRequest(fieldsToSubmit)); } return signUpAttempt .then(res => completeSignUpFlow({ signUp: res, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', protectCheckPath: 'protect-check', }), ) .catch(err => { /** * @experimental */ if ( isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'enterprise_connection_id_is_required_with_multiple_connections' ) { return navigate('./enterprise-connections'); } return handleError(err, fieldsToSubmit, card.setError); }) .finally(() => card.setIdle()); }; if (status.isLoading) { return ; } const canToggleEmailPhone = emailOrPhone(attributes, isProgressiveSignUp); const visibleFields = Object.entries(fields).filter(([key, opts]) => { // In case both email & phone are optional (emailOrPhone case), always show the active identifier if ((key === 'emailAddress' || key === 'phoneNumber') && canToggleEmailPhone) { return !!opts; } return showOptionalFields || opts?.required; }); const shouldShowForm = showFormFields(userSettings) && visibleFields.length > 0; const showOauthProviders = (!(hasTicket || hasExistingSignUpWithTicket) || missingRequirementsWithTicket) && userSettings.authenticatableSocialStrategies.length > 0; const showWeb3Providers = !(hasTicket || hasExistingSignUpWithTicket) && userSettings.web3FirstFactors.length > 0; const showAlternativePhoneCodeProviders = !(hasTicket || hasExistingSignUpWithTicket) && userSettings.alternativePhoneCodeChannels.length > 0; const onAlternativePhoneCodeUseAnotherMethod = () => { setAlternativePhoneCodeProvider(null); }; const onAlternativePhoneCodeProviderClick = (phoneCodeChannel: PhoneCodeChannel) => { const phoneCodeProvider: PhoneCodeChannelData | null = getAlternativePhoneCodeProviderData(phoneCodeChannel) || null; setAlternativePhoneCodeProvider(phoneCodeProvider); }; const blockedDetails = actionBlockedDetailsFrom(card.rawError); if (blockedDetails) { return ; } if (mode !== SIGN_UP_MODES.PUBLIC && !(hasTicket || hasExistingSignUpWithTicket)) { return ; } return ( {!alternativePhoneCodeProvider ? ( {card.error} {(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && ( )} {shouldShowForm && ( )} ) : ( )} ); } export const SignUpStart = withRedirectToSignUpTask( withRedirectToAfterSignUp(withCardStateProvider(SignUpStartInternal)), );