Skip to content

Commit 434b432

Browse files
octopertmilewski
andauthoredOct 31, 2024··
feat(elements,ui,clerk-js): Legal consent elements support and improvements (#4427)
Co-authored-by: Tom Milewski <me@tm.codes>
1 parent 69c8f4f commit 434b432

File tree

17 files changed

+250
-35
lines changed

17 files changed

+250
-35
lines changed
 

‎.changeset/odd-peas-hear.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/elements": minor
3+
---
4+
5+
Added support for `__experimental_legalAccepted` field

‎.changeset/witty-meals-retire.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@clerk/clerk-js": patch
3+
"@clerk/types": patch
4+
---
5+
6+
- Changed `__experimental_legalAccepted` checkbox Indicator element descriptor and element id
7+
- Changed `__experimental_legalAccepted` checkbox Label element descriptor and element id
8+
- Added two new element descriptors `formFieldCheckboxInput`, `formFieldCheckboxLabel`.

‎packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ function _SignUpStart(): JSX.Element {
281281
enableOAuthProviders={showOauthProviders}
282282
enableWeb3Providers={showWeb3Providers}
283283
continueSignUp={missingRequirementsWithTicket}
284-
legalAccepted={Boolean(formState.__experimental_legalAccepted.checked)}
284+
legalAccepted={Boolean(formState.__experimental_legalAccepted.checked) || undefined}
285285
/>
286286
)}
287287
{shouldShowForm && (

‎packages/clerk-js/src/ui/customizables/elementDescriptors.ts

+2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
9191
'formFieldRadioLabel',
9292
'formFieldRadioLabelTitle',
9393
'formFieldRadioLabelDescription',
94+
'formFieldCheckboxInput',
95+
'formFieldCheckboxLabel',
9496
'formFieldAction',
9597
'formFieldInput',
9698
'formFieldErrorText',

‎packages/clerk-js/src/ui/elements/FieldControl.tsx

+26-18
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Text,
1616
useLocalizations,
1717
} from '../customizables';
18+
import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors';
1819
import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../primitives/hooks';
1920
import type { PropsOfComponent } from '../styledSystem';
2021
import type { useFormControl as useFormControlUtil } from '../utils';
@@ -208,25 +209,32 @@ const PasswordInputElement = forwardRef<HTMLInputElement>((_, ref) => {
208209
);
209210
});
210211

211-
const CheckboxIndicator = forwardRef<HTMLInputElement>((_, ref) => {
212-
const formField = useFormField();
213-
const { placeholder, ...inputProps } = sanitizeInputProps(formField);
212+
type CheckboxIndicatorProps = {
213+
elementDescriptor?: ElementDescriptor;
214+
elementId?: ElementId;
215+
};
214216

215-
return (
216-
<CheckboxInput
217-
ref={ref}
218-
{...inputProps}
219-
elementDescriptor={descriptors.formFieldInput}
220-
elementId={descriptors.formFieldInput.setId(formField.fieldId)}
221-
focusRing={false}
222-
sx={t => ({
223-
width: 'fit-content',
224-
flexShrink: 0,
225-
marginTop: t.space.$0x5,
226-
})}
227-
/>
228-
);
229-
});
217+
const CheckboxIndicator = forwardRef<HTMLInputElement, CheckboxIndicatorProps>(
218+
({ elementDescriptor, elementId }, ref) => {
219+
const formField = useFormField();
220+
const { placeholder, ...inputProps } = sanitizeInputProps(formField);
221+
222+
return (
223+
<CheckboxInput
224+
ref={ref}
225+
{...inputProps}
226+
elementDescriptor={elementDescriptor || descriptors.formFieldInput}
227+
elementId={elementId || descriptors.formFieldInput.setId(formField.fieldId)}
228+
focusRing={false}
229+
sx={t => ({
230+
width: 'fit-content',
231+
flexShrink: 0,
232+
marginTop: t.space.$0x5,
233+
})}
234+
/>
235+
);
236+
},
237+
);
230238

231239
const CheckboxLabel = (props: { description?: string | LocalizationKey }) => {
232240
const { label, id } = useFormField();

‎packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ export const LegalCheckbox = (
6969
return (
7070
<Field.Root {...props}>
7171
<Flex justify='center'>
72-
<Field.CheckboxIndicator />
72+
<Field.CheckboxIndicator
73+
elementDescriptor={descriptors.formFieldCheckboxInput}
74+
elementId={descriptors.formFieldInput.setId('__experimental_legalAccepted')}
75+
/>
7376
<FormLabel
74-
elementDescriptor={descriptors.formFieldRadioLabel}
77+
elementDescriptor={descriptors.formFieldCheckboxLabel}
7578
htmlFor={props.itemID}
7679
sx={t => ({
7780
paddingLeft: t.space.$1x5,

‎packages/elements/src/internals/machines/form/form.machine.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,14 @@ export const FormMachine = setup({
7474
throw new Error('Field name is required');
7575
}
7676

77-
if (context.fields.has(params.name)) {
77+
const fieldsNameMap: Record<string, string> = {
78+
legalAccepted: '__experimental_legalAccepted',
79+
};
80+
const fieldName = fieldsNameMap[params.name] || params.name;
81+
82+
if (context.fields.has(fieldName)) {
7883
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
79-
context.fields.get(params.name)!.feedback = params.feedback;
84+
context.fields.get(fieldName)!.feedback = params.feedback;
8085
}
8186

8287
return context.fields;

‎packages/elements/src/internals/machines/sign-up/continue.machine.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { snakeToCamel } from '@clerk/shared/underscore';
22
import type { SignUpResource } from '@clerk/types';
33
import type { DoneActorEvent } from 'xstate';
4-
import { fromPromise, setup } from 'xstate';
4+
import { fromPromise, not, or, setup } from 'xstate';
55

66
import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants';
77
import type { FormDefaultValues, FormFields } from '~/internals/machines/form';
@@ -62,6 +62,21 @@ export const SignUpContinueMachine = setup({
6262
context.parent.send({ type: 'NEXT', resource: (event as unknown as DoneActorEvent<SignUpResource>).output }),
6363
sendToLoading,
6464
},
65+
guards: {
66+
isStatusMissingRequirements: ({ context }) =>
67+
context.parent.getSnapshot().context.clerk?.client?.signUp?.status === 'missing_requirements',
68+
hasMetPreviousMissingRequirements: ({ context }) => {
69+
const signUp = context.parent.getSnapshot().context.clerk.client.signUp;
70+
71+
const fields = context.formRef.getSnapshot().context.fields;
72+
const signUpMissingFields = signUp.missingFields.map(snakeToCamel);
73+
const missingFields = Array.from(context.formRef.getSnapshot().context.fields.keys()).filter(key => {
74+
return !signUpMissingFields.includes(key) && !fields.get(key)?.value && !fields.get(key)?.checked;
75+
});
76+
77+
return missingFields.length === 0;
78+
},
79+
},
6580
types: {} as SignUpContinueSchema,
6681
}).createMachine({
6782
id: SignUpContinueMachineId,
@@ -82,6 +97,7 @@ export const SignUpContinueMachine = setup({
8297
description: 'Waiting for user input',
8398
on: {
8499
SUBMIT: {
100+
guard: or(['hasMetPreviousMissingRequirements', not('isStatusMissingRequirements')]),
85101
target: 'Attempting',
86102
reenter: true,
87103
},

‎packages/elements/src/internals/machines/sign-up/utils/fields-to-params.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import type { SignUpCreateParams, SignUpUpdateParams } from '@clerk/types';
22

33
import type { FormFields } from '~/internals/machines/form';
44

5-
const SignUpAdditionalKeys = ['firstName', 'lastName', 'emailAddress', 'username', 'password', 'phoneNumber'] as const;
5+
const SignUpAdditionalKeys = [
6+
'firstName',
7+
'lastName',
8+
'emailAddress',
9+
'username',
10+
'password',
11+
'phoneNumber',
12+
'__experimental_legalAccepted',
13+
] as const;
614

715
type SignUpAdditionalKeys = (typeof SignUpAdditionalKeys)[number];
816

@@ -17,10 +25,16 @@ export function fieldsToSignUpParams<T extends SignUpCreateParams | SignUpUpdate
1725
): Pick<T, SignUpAdditionalKeys> {
1826
const params: SignUpUpdateParams = {};
1927

20-
fields.forEach(({ value }, key) => {
21-
if (isSignUpParam(key) && value !== undefined) {
28+
fields.forEach(({ value, checked, type }, key) => {
29+
if (isSignUpParam(key) && value !== undefined && type !== 'checkbox') {
30+
// @ts-expect-error - Type is not narrowed to string
2231
params[key] = value as string;
2332
}
33+
34+
if (isSignUpParam(key) && checked !== undefined && type === 'checkbox') {
35+
// @ts-expect-error - Type is not narrowed to boolean
36+
params[key] = checked as boolean;
37+
}
2438
});
2539

2640
return params;

‎packages/elements/src/internals/machines/third-party/third-party.machine.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,17 @@ export const ThirdPartyMachine = setup({
7777
input: ({ context, event }) => {
7878
assertEvent(event, 'REDIRECT');
7979

80+
const legalAcceptedField = context.formRef
81+
.getSnapshot()
82+
.context.fields.get('__experimental_legalAccepted')?.checked;
83+
8084
return {
8185
basePath: context.basePath,
8286
flow: context.flow,
83-
params: event.params,
87+
params: {
88+
...event.params,
89+
__experimental_legalAccepted: legalAcceptedField || undefined,
90+
},
8491
parent: context.parent,
8592
};
8693
},

‎packages/types/src/appearance.ts

+2
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ export type ElementsConfig = {
210210
formFieldRadioLabel: WithOptions<FieldId, ControlState>;
211211
formFieldRadioLabelTitle: WithOptions<FieldId, ControlState>;
212212
formFieldRadioLabelDescription: WithOptions<FieldId, ControlState>;
213+
formFieldCheckboxInput: WithOptions<FieldId, ControlState>;
214+
formFieldCheckboxLabel: WithOptions<FieldId, ControlState>;
213215
formFieldAction: WithOptions<FieldId, ControlState>;
214216
formFieldInput: WithOptions<FieldId, ControlState>;
215217
formFieldErrorText: WithOptions<FieldId, ControlState>;
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as Common from '@clerk/elements/common';
2+
import React from 'react';
3+
4+
import { useAppearance } from '~/contexts';
5+
import { useEnvironment } from '~/hooks/use-environment';
6+
import { useLocalizations } from '~/hooks/use-localizations';
7+
import * as Field from '~/primitives/field';
8+
9+
import { LinkRenderer } from './link-renderer';
10+
11+
export function LegalAcceptedField({
12+
className,
13+
checked = false,
14+
...restProps
15+
}: Omit<React.ComponentProps<typeof Common.Input>, 'type'>) {
16+
const { t } = useLocalizations();
17+
const { displayConfig } = useEnvironment();
18+
const { parsedAppearance } = useAppearance();
19+
const termsUrl = parsedAppearance.options.termsPageUrl || displayConfig.termsUrl;
20+
const privacyPolicyUrl = parsedAppearance.options.privacyPageUrl || displayConfig.privacyPolicyUrl;
21+
22+
let localizedText: string | undefined;
23+
24+
if (termsUrl && privacyPolicyUrl) {
25+
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__termsOfServiceAndPrivacyPolicy', {
26+
termsOfServiceLink: termsUrl,
27+
privacyPolicyLink: privacyPolicyUrl,
28+
});
29+
} else if (termsUrl) {
30+
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyTermsOfService', {
31+
termsOfServiceLink: termsUrl,
32+
});
33+
} else if (privacyPolicyUrl) {
34+
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyPrivacyPolicy', {
35+
privacyPolicyLink: privacyPolicyUrl,
36+
});
37+
}
38+
39+
return (
40+
<Common.Field
41+
name='__experimental_legalAccepted'
42+
asChild
43+
>
44+
<Field.Root>
45+
<div className='flex justify-center gap-2'>
46+
<Common.Input
47+
type='checkbox'
48+
asChild
49+
checked={checked}
50+
{...restProps}
51+
>
52+
<Field.Checkbox />
53+
</Common.Input>
54+
55+
<Common.Label asChild>
56+
<Field.Label>
57+
<span>
58+
<LinkRenderer
59+
text={localizedText || ''}
60+
className='underline underline-offset-2'
61+
/>
62+
</span>
63+
</Field.Label>
64+
</Common.Label>
65+
</div>
66+
67+
<Common.FieldError asChild>
68+
{({ message }) => {
69+
return <Field.Message intent='error'>{message}</Field.Message>;
70+
}}
71+
</Common.FieldError>
72+
</Field.Root>
73+
</Common.Field>
74+
);
75+
}
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, { memo, useMemo } from 'react';
2+
3+
interface LinkRendererProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children' | 'class'> {
4+
text: string;
5+
className?: string;
6+
}
7+
8+
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; // parses [text](url)
9+
10+
export const LinkRenderer: React.FC<LinkRendererProps> = memo(({ text, ...linkProps }) => {
11+
const memoizedLinkProps = useMemo(() => linkProps, [linkProps]);
12+
13+
const renderedContent = useMemo(() => {
14+
const parts: (string | JSX.Element)[] = [];
15+
let lastIndex = 0;
16+
17+
text.replace(LINK_REGEX, (match, linkText, url, offset) => {
18+
if (offset > lastIndex) {
19+
parts.push(text.slice(lastIndex, offset));
20+
}
21+
parts.push(
22+
<a
23+
{...memoizedLinkProps}
24+
href={url}
25+
target='_blank'
26+
rel='noopener noreferrer'
27+
key={offset}
28+
>
29+
{linkText}
30+
</a>,
31+
);
32+
lastIndex = offset + match.length;
33+
return match;
34+
});
35+
36+
if (lastIndex < text.length) {
37+
parts.push(text.slice(lastIndex));
38+
}
39+
40+
return parts;
41+
}, [text, memoizedLinkProps]);
42+
43+
return renderedContent;
44+
});

‎packages/ui/src/components/sign-up/steps/continue.tsx

+21-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { EmailField } from '~/common/email-field';
66
import { FirstNameField } from '~/common/first-name-field';
77
import { GlobalError } from '~/common/global-error';
88
import { LastNameField } from '~/common/last-name-field';
9+
import { LegalAcceptedField } from '~/common/legal-accepted';
910
import { PasswordField } from '~/common/password-field';
1011
import { PhoneNumberField } from '~/common/phone-number-field';
1112
import { RouterLink } from '~/common/router-link';
@@ -14,6 +15,7 @@ import { LOCALIZATION_NEEDED } from '~/constants/localizations';
1415
import { useAttributes } from '~/hooks/use-attributes';
1516
import { useCard } from '~/hooks/use-card';
1617
import { useDevModeWarning } from '~/hooks/use-dev-mode-warning';
18+
import { useEnvironment } from '~/hooks/use-environment';
1719
import { useLocalizations } from '~/hooks/use-localizations';
1820
import { useOptions } from '~/hooks/use-options';
1921
import { Button } from '~/primitives/button';
@@ -24,11 +26,23 @@ export function SignUpContinue() {
2426
const clerk = useClerk();
2527
const { signInUrl } = useOptions();
2628
const { t } = useLocalizations();
29+
const environment = useEnvironment();
30+
const { client } = useClerk();
31+
const { missingFields } = client.signUp;
2732
const { enabled: firstNameEnabled, required: firstNameRequired } = useAttributes('first_name');
2833
const { enabled: lastNameEnabled, required: lastNameRequired } = useAttributes('last_name');
2934
const { enabled: usernameEnabled, required: usernameRequired } = useAttributes('username');
3035
const { enabled: phoneNumberEnabled, required: phoneNumberRequired } = useAttributes('phone_number');
3136
const { enabled: passwordEnabled, required: passwordRequired } = useAttributes('password');
37+
const legalConsentEnabled = environment.userSettings.signUp.legal_consent_enabled;
38+
const legalConsentMissing = missingFields.includes('legal_accepted');
39+
const showFirstName = firstNameEnabled && firstNameRequired && missingFields.includes('first_name');
40+
const showLastName = lastNameEnabled && lastNameRequired && missingFields.includes('last_name');
41+
const showUserName = usernameEnabled && usernameRequired && missingFields.includes('username');
42+
const showPhoneNumber = phoneNumberEnabled && phoneNumberRequired && missingFields.includes('phone_number');
43+
const showPassword = passwordEnabled && passwordRequired && missingFields.includes('password');
44+
const showEmail = missingFields.includes('email_address');
45+
const showLegalConsent = legalConsentEnabled && legalConsentMissing;
3246
const isDev = useDevModeWarning();
3347
const { logoProps, footerProps } = useCard();
3448

@@ -55,7 +69,7 @@ export function SignUpContinue() {
5569

5670
<Card.Body>
5771
<div className='flex flex-col gap-y-4'>
58-
{firstNameEnabled && lastNameEnabled ? (
72+
{showFirstName && showLastName ? (
5973
<div className='flex gap-4 empty:hidden'>
6074
<FirstNameField
6175
required={firstNameRequired}
@@ -68,30 +82,32 @@ export function SignUpContinue() {
6882
</div>
6983
) : null}
7084

71-
{usernameEnabled ? (
85+
{showUserName ? (
7286
<UsernameField
7387
required={usernameRequired}
7488
disabled={isGlobalLoading}
7589
/>
7690
) : null}
7791

78-
{phoneNumberEnabled ? (
92+
{showPhoneNumber ? (
7993
<PhoneNumberField
8094
required={phoneNumberRequired}
8195
disabled={isGlobalLoading}
8296
/>
8397
) : null}
8498

85-
<EmailField disabled={isGlobalLoading} />
99+
{showEmail && <EmailField disabled={isGlobalLoading} />}
86100

87-
{passwordEnabled && passwordRequired ? (
101+
{showPassword ? (
88102
<PasswordField
89103
validatePassword
90104
label={t('formFieldLabel__password')}
91105
required={passwordRequired}
92106
disabled={isGlobalLoading}
93107
/>
94108
) : null}
109+
110+
{showLegalConsent && <LegalAcceptedField />}
95111
</div>
96112
</Card.Body>
97113
<Card.Actions>

‎packages/ui/src/components/sign-up/steps/start.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { EmailOrPhoneNumberField } from '~/common/email-or-phone-number-field';
88
import { FirstNameField } from '~/common/first-name-field';
99
import { GlobalError } from '~/common/global-error';
1010
import { LastNameField } from '~/common/last-name-field';
11+
import { LegalAcceptedField } from '~/common/legal-accepted';
1112
import { PasswordField } from '~/common/password-field';
1213
import { PhoneNumberField } from '~/common/phone-number-field';
1314
import { RouterLink } from '~/common/router-link';
@@ -54,6 +55,7 @@ export function SignUpStart() {
5455
const isDev = useDevModeWarning();
5556
const { options } = useAppearance().parsedAppearance;
5657
const { logoProps, footerProps } = useCard();
58+
const legalConsentEnabled = userSettings.signUp.legal_consent_enabled;
5759

5860
return (
5961
<Common.Loading scope='global'>
@@ -144,10 +146,13 @@ export function SignUpStart() {
144146

145147
{options.socialButtonsPlacement === 'bottom' ? connectionsWithSeperator.reverse() : null}
146148

149+
{legalConsentEnabled && hasConnection && !hasIdentifier && <LegalAcceptedField />}
150+
147151
{userSettings.signUp.captcha_enabled ? <SignUp.Captcha className='empty:hidden' /> : null}
148152
</Card.Body>
149-
{hasConnection || hasIdentifier ? (
153+
{hasIdentifier ? (
150154
<Card.Actions>
155+
{legalConsentEnabled && hasIdentifier && <LegalAcceptedField />}
151156
<Common.Loading scope='submit'>
152157
{isSubmitting => {
153158
return (

‎packages/ui/src/primitives/card.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export const Body = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
309309
const cardActionsLayoutStyle = {
310310
cardActions: {
311311
className: [
312-
'z-1 flex flex-col gap-3',
312+
'z-1 flex flex-col gap-6',
313313
// Note:
314314
// Prevents underline interractions triggering outside of the link text
315315
// https://linear.app/clerk/issue/SDKI-192/#comment-ebf943b0

‎packages/ui/src/utils/make-localizable.ts

+5
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,16 @@ const numeric = (val: Date | number | string, locale?: string) => {
161161
}
162162
};
163163

164+
const link = (val: string, label?: string) => {
165+
return `[${label}](${val})`;
166+
};
167+
164168
const MODIFIERS = {
165169
titleize,
166170
timeString,
167171
weekday,
168172
numeric,
173+
link,
169174
} as const;
170175

171176
const applyTokenExpressions = (s: string, expressions: TokenExpression[], tokens: Tokens) => {

0 commit comments

Comments
 (0)
Please sign in to comment.