diff --git a/.changeset/odd-peas-hear.md b/.changeset/odd-peas-hear.md new file mode 100644 index 0000000000..6ff76d69a1 --- /dev/null +++ b/.changeset/odd-peas-hear.md @@ -0,0 +1,5 @@ +--- +"@clerk/elements": minor +--- + +Added support for `__experimental_legalAccepted` field diff --git a/.changeset/witty-meals-retire.md b/.changeset/witty-meals-retire.md new file mode 100644 index 0000000000..ba6502d60b --- /dev/null +++ b/.changeset/witty-meals-retire.md @@ -0,0 +1,8 @@ +--- +"@clerk/clerk-js": patch +"@clerk/types": patch +--- + +- Changed `__experimental_legalAccepted` checkbox Indicator element descriptor and element id +- Changed `__experimental_legalAccepted` checkbox Label element descriptor and element id +- Added two new element descriptors `formFieldCheckboxInput`, `formFieldCheckboxLabel`. diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index ff059fccae..3ef614a3bc 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -281,7 +281,7 @@ function _SignUpStart(): JSX.Element { enableOAuthProviders={showOauthProviders} enableWeb3Providers={showWeb3Providers} continueSignUp={missingRequirementsWithTicket} - legalAccepted={Boolean(formState.__experimental_legalAccepted.checked)} + legalAccepted={Boolean(formState.__experimental_legalAccepted.checked) || undefined} /> )} {shouldShowForm && ( diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 3a2cb8cc19..c60bab033a 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -91,6 +91,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'formFieldRadioLabel', 'formFieldRadioLabelTitle', 'formFieldRadioLabelDescription', + 'formFieldCheckboxInput', + 'formFieldCheckboxLabel', 'formFieldAction', 'formFieldInput', 'formFieldErrorText', diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx index 18ea8f9854..545f339ee2 100644 --- a/packages/clerk-js/src/ui/elements/FieldControl.tsx +++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx @@ -15,6 +15,7 @@ import { Text, useLocalizations, } from '../customizables'; +import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../primitives/hooks'; import type { PropsOfComponent } from '../styledSystem'; import type { useFormControl as useFormControlUtil } from '../utils'; @@ -208,25 +209,32 @@ const PasswordInputElement = forwardRef((_, ref) => { ); }); -const CheckboxIndicator = forwardRef((_, ref) => { - const formField = useFormField(); - const { placeholder, ...inputProps } = sanitizeInputProps(formField); +type CheckboxIndicatorProps = { + elementDescriptor?: ElementDescriptor; + elementId?: ElementId; +}; - return ( - ({ - width: 'fit-content', - flexShrink: 0, - marginTop: t.space.$0x5, - })} - /> - ); -}); +const CheckboxIndicator = forwardRef( + ({ elementDescriptor, elementId }, ref) => { + const formField = useFormField(); + const { placeholder, ...inputProps } = sanitizeInputProps(formField); + + return ( + ({ + width: 'fit-content', + flexShrink: 0, + marginTop: t.space.$0x5, + })} + /> + ); + }, +); const CheckboxLabel = (props: { description?: string | LocalizationKey }) => { const { label, id } = useFormField(); diff --git a/packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx b/packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx index 4f2ac72bd0..005688be4c 100644 --- a/packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx +++ b/packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx @@ -69,9 +69,12 @@ export const LegalCheckbox = ( return ( - + ({ paddingLeft: t.space.$1x5, diff --git a/packages/elements/src/internals/machines/form/form.machine.ts b/packages/elements/src/internals/machines/form/form.machine.ts index 28393b59f1..ccf1190863 100644 --- a/packages/elements/src/internals/machines/form/form.machine.ts +++ b/packages/elements/src/internals/machines/form/form.machine.ts @@ -74,9 +74,14 @@ export const FormMachine = setup({ throw new Error('Field name is required'); } - if (context.fields.has(params.name)) { + const fieldsNameMap: Record = { + legalAccepted: '__experimental_legalAccepted', + }; + const fieldName = fieldsNameMap[params.name] || params.name; + + if (context.fields.has(fieldName)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - context.fields.get(params.name)!.feedback = params.feedback; + context.fields.get(fieldName)!.feedback = params.feedback; } return context.fields; diff --git a/packages/elements/src/internals/machines/sign-up/continue.machine.ts b/packages/elements/src/internals/machines/sign-up/continue.machine.ts index 561d57604c..62cdeb3eea 100644 --- a/packages/elements/src/internals/machines/sign-up/continue.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/continue.machine.ts @@ -1,7 +1,7 @@ import { snakeToCamel } from '@clerk/shared/underscore'; import type { SignUpResource } from '@clerk/types'; import type { DoneActorEvent } from 'xstate'; -import { fromPromise, setup } from 'xstate'; +import { fromPromise, not, or, setup } from 'xstate'; import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import type { FormDefaultValues, FormFields } from '~/internals/machines/form'; @@ -62,6 +62,21 @@ export const SignUpContinueMachine = setup({ context.parent.send({ type: 'NEXT', resource: (event as unknown as DoneActorEvent).output }), sendToLoading, }, + guards: { + isStatusMissingRequirements: ({ context }) => + context.parent.getSnapshot().context.clerk?.client?.signUp?.status === 'missing_requirements', + hasMetPreviousMissingRequirements: ({ context }) => { + const signUp = context.parent.getSnapshot().context.clerk.client.signUp; + + const fields = context.formRef.getSnapshot().context.fields; + const signUpMissingFields = signUp.missingFields.map(snakeToCamel); + const missingFields = Array.from(context.formRef.getSnapshot().context.fields.keys()).filter(key => { + return !signUpMissingFields.includes(key) && !fields.get(key)?.value && !fields.get(key)?.checked; + }); + + return missingFields.length === 0; + }, + }, types: {} as SignUpContinueSchema, }).createMachine({ id: SignUpContinueMachineId, @@ -82,6 +97,7 @@ export const SignUpContinueMachine = setup({ description: 'Waiting for user input', on: { SUBMIT: { + guard: or(['hasMetPreviousMissingRequirements', not('isStatusMissingRequirements')]), target: 'Attempting', reenter: true, }, diff --git a/packages/elements/src/internals/machines/sign-up/utils/fields-to-params.ts b/packages/elements/src/internals/machines/sign-up/utils/fields-to-params.ts index e704b9d00b..80bfc16cac 100644 --- a/packages/elements/src/internals/machines/sign-up/utils/fields-to-params.ts +++ b/packages/elements/src/internals/machines/sign-up/utils/fields-to-params.ts @@ -2,7 +2,15 @@ import type { SignUpCreateParams, SignUpUpdateParams } from '@clerk/types'; import type { FormFields } from '~/internals/machines/form'; -const SignUpAdditionalKeys = ['firstName', 'lastName', 'emailAddress', 'username', 'password', 'phoneNumber'] as const; +const SignUpAdditionalKeys = [ + 'firstName', + 'lastName', + 'emailAddress', + 'username', + 'password', + 'phoneNumber', + '__experimental_legalAccepted', +] as const; type SignUpAdditionalKeys = (typeof SignUpAdditionalKeys)[number]; @@ -17,10 +25,16 @@ export function fieldsToSignUpParams { const params: SignUpUpdateParams = {}; - fields.forEach(({ value }, key) => { - if (isSignUpParam(key) && value !== undefined) { + fields.forEach(({ value, checked, type }, key) => { + if (isSignUpParam(key) && value !== undefined && type !== 'checkbox') { + // @ts-expect-error - Type is not narrowed to string params[key] = value as string; } + + if (isSignUpParam(key) && checked !== undefined && type === 'checkbox') { + // @ts-expect-error - Type is not narrowed to boolean + params[key] = checked as boolean; + } }); return params; diff --git a/packages/elements/src/internals/machines/third-party/third-party.machine.ts b/packages/elements/src/internals/machines/third-party/third-party.machine.ts index fdca1ba57e..ad2d7d05cd 100644 --- a/packages/elements/src/internals/machines/third-party/third-party.machine.ts +++ b/packages/elements/src/internals/machines/third-party/third-party.machine.ts @@ -77,10 +77,17 @@ export const ThirdPartyMachine = setup({ input: ({ context, event }) => { assertEvent(event, 'REDIRECT'); + const legalAcceptedField = context.formRef + .getSnapshot() + .context.fields.get('__experimental_legalAccepted')?.checked; + return { basePath: context.basePath, flow: context.flow, - params: event.params, + params: { + ...event.params, + __experimental_legalAccepted: legalAcceptedField || undefined, + }, parent: context.parent, }; }, diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 88b566cb38..cc7173cdaf 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -210,6 +210,8 @@ export type ElementsConfig = { formFieldRadioLabel: WithOptions; formFieldRadioLabelTitle: WithOptions; formFieldRadioLabelDescription: WithOptions; + formFieldCheckboxInput: WithOptions; + formFieldCheckboxLabel: WithOptions; formFieldAction: WithOptions; formFieldInput: WithOptions; formFieldErrorText: WithOptions; diff --git a/packages/ui/src/common/legal-accepted.tsx b/packages/ui/src/common/legal-accepted.tsx new file mode 100644 index 0000000000..d95ca06923 --- /dev/null +++ b/packages/ui/src/common/legal-accepted.tsx @@ -0,0 +1,75 @@ +import * as Common from '@clerk/elements/common'; +import React from 'react'; + +import { useAppearance } from '~/contexts'; +import { useEnvironment } from '~/hooks/use-environment'; +import { useLocalizations } from '~/hooks/use-localizations'; +import * as Field from '~/primitives/field'; + +import { LinkRenderer } from './link-renderer'; + +export function LegalAcceptedField({ + className, + checked = false, + ...restProps +}: Omit, 'type'>) { + const { t } = useLocalizations(); + const { displayConfig } = useEnvironment(); + const { parsedAppearance } = useAppearance(); + const termsUrl = parsedAppearance.options.termsPageUrl || displayConfig.termsUrl; + const privacyPolicyUrl = parsedAppearance.options.privacyPageUrl || displayConfig.privacyPolicyUrl; + + let localizedText: string | undefined; + + if (termsUrl && privacyPolicyUrl) { + localizedText = t('signUp.__experimental_legalConsent.checkbox.label__termsOfServiceAndPrivacyPolicy', { + termsOfServiceLink: termsUrl, + privacyPolicyLink: privacyPolicyUrl, + }); + } else if (termsUrl) { + localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyTermsOfService', { + termsOfServiceLink: termsUrl, + }); + } else if (privacyPolicyUrl) { + localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyPrivacyPolicy', { + privacyPolicyLink: privacyPolicyUrl, + }); + } + + return ( + + +
+ + + + + + + + + + + +
+ + + {({ message }) => { + return {message}; + }} + +
+
+ ); +} diff --git a/packages/ui/src/common/link-renderer.tsx b/packages/ui/src/common/link-renderer.tsx new file mode 100644 index 0000000000..98c2e470d2 --- /dev/null +++ b/packages/ui/src/common/link-renderer.tsx @@ -0,0 +1,44 @@ +import React, { memo, useMemo } from 'react'; + +interface LinkRendererProps extends Omit, 'href' | 'children' | 'class'> { + text: string; + className?: string; +} + +const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; // parses [text](url) + +export const LinkRenderer: React.FC = memo(({ text, ...linkProps }) => { + const memoizedLinkProps = useMemo(() => linkProps, [linkProps]); + + const renderedContent = useMemo(() => { + const parts: (string | JSX.Element)[] = []; + let lastIndex = 0; + + text.replace(LINK_REGEX, (match, linkText, url, offset) => { + if (offset > lastIndex) { + parts.push(text.slice(lastIndex, offset)); + } + parts.push( + + {linkText} + , + ); + lastIndex = offset + match.length; + return match; + }); + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; + }, [text, memoizedLinkProps]); + + return renderedContent; +}); diff --git a/packages/ui/src/components/sign-up/steps/continue.tsx b/packages/ui/src/components/sign-up/steps/continue.tsx index 3690611737..35ef4e519c 100644 --- a/packages/ui/src/components/sign-up/steps/continue.tsx +++ b/packages/ui/src/components/sign-up/steps/continue.tsx @@ -6,6 +6,7 @@ import { EmailField } from '~/common/email-field'; import { FirstNameField } from '~/common/first-name-field'; import { GlobalError } from '~/common/global-error'; import { LastNameField } from '~/common/last-name-field'; +import { LegalAcceptedField } from '~/common/legal-accepted'; import { PasswordField } from '~/common/password-field'; import { PhoneNumberField } from '~/common/phone-number-field'; import { RouterLink } from '~/common/router-link'; @@ -14,6 +15,7 @@ import { LOCALIZATION_NEEDED } from '~/constants/localizations'; import { useAttributes } from '~/hooks/use-attributes'; import { useCard } from '~/hooks/use-card'; import { useDevModeWarning } from '~/hooks/use-dev-mode-warning'; +import { useEnvironment } from '~/hooks/use-environment'; import { useLocalizations } from '~/hooks/use-localizations'; import { useOptions } from '~/hooks/use-options'; import { Button } from '~/primitives/button'; @@ -24,11 +26,23 @@ export function SignUpContinue() { const clerk = useClerk(); const { signInUrl } = useOptions(); const { t } = useLocalizations(); + const environment = useEnvironment(); + const { client } = useClerk(); + const { missingFields } = client.signUp; const { enabled: firstNameEnabled, required: firstNameRequired } = useAttributes('first_name'); const { enabled: lastNameEnabled, required: lastNameRequired } = useAttributes('last_name'); const { enabled: usernameEnabled, required: usernameRequired } = useAttributes('username'); const { enabled: phoneNumberEnabled, required: phoneNumberRequired } = useAttributes('phone_number'); const { enabled: passwordEnabled, required: passwordRequired } = useAttributes('password'); + const legalConsentEnabled = environment.userSettings.signUp.legal_consent_enabled; + const legalConsentMissing = missingFields.includes('legal_accepted'); + const showFirstName = firstNameEnabled && firstNameRequired && missingFields.includes('first_name'); + const showLastName = lastNameEnabled && lastNameRequired && missingFields.includes('last_name'); + const showUserName = usernameEnabled && usernameRequired && missingFields.includes('username'); + const showPhoneNumber = phoneNumberEnabled && phoneNumberRequired && missingFields.includes('phone_number'); + const showPassword = passwordEnabled && passwordRequired && missingFields.includes('password'); + const showEmail = missingFields.includes('email_address'); + const showLegalConsent = legalConsentEnabled && legalConsentMissing; const isDev = useDevModeWarning(); const { logoProps, footerProps } = useCard(); @@ -55,7 +69,7 @@ export function SignUpContinue() {
- {firstNameEnabled && lastNameEnabled ? ( + {showFirstName && showLastName ? (
) : null} - {usernameEnabled ? ( + {showUserName ? ( ) : null} - {phoneNumberEnabled ? ( + {showPhoneNumber ? ( ) : null} - + {showEmail && } - {passwordEnabled && passwordRequired ? ( + {showPassword ? ( ) : null} + + {showLegalConsent && }
diff --git a/packages/ui/src/components/sign-up/steps/start.tsx b/packages/ui/src/components/sign-up/steps/start.tsx index 90450609b6..aa31645073 100644 --- a/packages/ui/src/components/sign-up/steps/start.tsx +++ b/packages/ui/src/components/sign-up/steps/start.tsx @@ -8,6 +8,7 @@ import { EmailOrPhoneNumberField } from '~/common/email-or-phone-number-field'; import { FirstNameField } from '~/common/first-name-field'; import { GlobalError } from '~/common/global-error'; import { LastNameField } from '~/common/last-name-field'; +import { LegalAcceptedField } from '~/common/legal-accepted'; import { PasswordField } from '~/common/password-field'; import { PhoneNumberField } from '~/common/phone-number-field'; import { RouterLink } from '~/common/router-link'; @@ -54,6 +55,7 @@ export function SignUpStart() { const isDev = useDevModeWarning(); const { options } = useAppearance().parsedAppearance; const { logoProps, footerProps } = useCard(); + const legalConsentEnabled = userSettings.signUp.legal_consent_enabled; return ( @@ -144,10 +146,13 @@ export function SignUpStart() { {options.socialButtonsPlacement === 'bottom' ? connectionsWithSeperator.reverse() : null} + {legalConsentEnabled && hasConnection && !hasIdentifier && } + {userSettings.signUp.captcha_enabled ? : null} - {hasConnection || hasIdentifier ? ( + {hasIdentifier ? ( + {legalConsentEnabled && hasIdentifier && } {isSubmitting => { return ( diff --git a/packages/ui/src/primitives/card.tsx b/packages/ui/src/primitives/card.tsx index ca184ab910..0854d22cba 100644 --- a/packages/ui/src/primitives/card.tsx +++ b/packages/ui/src/primitives/card.tsx @@ -309,7 +309,7 @@ export const Body = React.forwardRef { } }; +const link = (val: string, label?: string) => { + return `[${label}](${val})`; +}; + const MODIFIERS = { titleize, timeString, weekday, numeric, + link, } as const; const applyTokenExpressions = (s: string, expressions: TokenExpression[], tokens: Tokens) => {