Skip to content

Commit

Permalink
feat(clerk-js): Form.PhoneNumber and Form.OTPInput (#2201)
Browse files Browse the repository at this point in the history
* feat(clerk-js): Form.PhoneNumber and Form.OTP

* fix(clerk-js): Forward ref for Form.PasswordInput

* feat(clerk-js): Sync changes to `ui.retheme`

* chore(clerk-js): Add empty changeset

* chore(clerk-js): Remove CodeForm and decouple OTPInput logic from UI structure

* chore(clerk-js): Sync changes to ui.retheme

* chore(clerk-js): Fix conflicts
  • Loading branch information
panteliselef authored Nov 25, 2023
1 parent 0307957 commit 63713c3
Show file tree
Hide file tree
Showing 25 changed files with 652 additions and 602 deletions.
2 changes: 2 additions & 0 deletions .changeset/lemon-rockets-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ import {
FormButtonContainer,
FormButtons,
useCardState,
useCodeControl,
useFieldOTP,
withCardStateProvider,
} from '../../elements';
import { CodeForm } from '../../elements/CodeForm';
import { useFetch, useLoadingStatus } from '../../hooks';
import { useFetch } from '../../hooks';
import { useRouter } from '../../router';
import { handleError, sleep, useFormControl } from '../../utils';
import { handleError, useFormControl } from '../../utils';
import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';

export const VerifyDomainPage = withCardStateProvider(() => {
Expand All @@ -25,8 +24,6 @@ export const VerifyDomainPage = withCardStateProvider(() => {
const { organization } = useCoreOrganization();
const { params, navigate } = useRouter();

const [success, setSuccess] = React.useState(false);

const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, {
domainId: params.id,
});
Expand All @@ -36,19 +33,14 @@ export const VerifyDomainPage = withCardStateProvider(() => {
});

const breadcrumbTitle = localizationKeys('organizationProfile.profilePage.domainSection.title');

const status = useLoadingStatus();

const codeControlState = useFormControl('code', '');
const codeControl = useCodeControl(codeControlState);

const wizard = useWizard({ onNextStep: () => card.setError(undefined) });

const emailField = useFormControl('affiliationEmailAddress', '', {
type: 'text',
label: localizationKeys('formFieldLabel__organizationDomainEmailAddress'),
placeholder: localizationKeys('formFieldInputPlaceholder__organizationDomainEmailAddress'),
infoText: localizationKeys('formFieldLabel__organizationDomainEmailAddressDescription'),
isRequired: true,
});

const affiliationEmailAddressRef = useRef<string>();
Expand All @@ -60,11 +52,6 @@ export const VerifyDomainPage = withCardStateProvider(() => {
},
);

const resolve = async () => {
setSuccess(true);
await sleep(750);
};

const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => {
domain
?.attemptAffiliationVerification?.({ code })
Expand All @@ -78,30 +65,21 @@ export const VerifyDomainPage = withCardStateProvider(() => {
.catch(err => reject(err));
};

const reject = async (err: any) => {
handleError(err, [codeControlState], card.setError);
status.setIdle();
await sleep(750);
codeControl.reset();
};

codeControl.onCodeEntryFinished(code => {
status.setLoading();
codeControlState.setError(undefined);
action(code, resolve, reject);
const otp = useFieldOTP({
onCodeEntryFinished: (code, resolve, reject) => {
action(code, resolve, reject);
},
onResendCodeClicked: () => {
domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => {
handleError(err, [emailField], card.setError);
});
},
});

if (!organization || !organizationSettings) {
return null;
}

const handleResend = () => {
codeControl.reset();
domain?.prepareAffiliationVerification({ affiliationEmailAddress: emailField.value }).catch(err => {
handleError(err, [emailField], card.setError);
});
};

const dataChanged = organization.name !== emailField.value;
const canSubmit = dataChanged;
const emailDomainSuffix = `@${domain?.name}`;
Expand Down Expand Up @@ -155,7 +133,6 @@ export const VerifyDomainPage = withCardStateProvider(() => {
{...emailField.props}
autoFocus
groupSuffix={emailDomainSuffix}
isRequired
/>
</Form.ControlRow>
<FormButtons isDisabled={!canSubmit} />
Expand All @@ -168,14 +145,11 @@ export const VerifyDomainPage = withCardStateProvider(() => {
headerSubtitle={subtitleVerificationCodeScreen}
Breadcrumbs={OrganizationProfileBreadcrumbs}
>
<CodeForm
title={localizationKeys('organizationProfile.verifyDomainPage.formTitle')}
subtitle={localizationKeys('organizationProfile.verifyDomainPage.formSubtitle')}
<Form.OTPInput
{...otp}
label={localizationKeys('organizationProfile.verifyDomainPage.formTitle')}
description={localizationKeys('organizationProfile.verifyDomainPage.formSubtitle')}
resendButton={localizationKeys('organizationProfile.verifyDomainPage.resendButton')}
isLoading={status.isLoading}
success={success}
codeControl={codeControl}
onResendCodeClicked={handleResend}
/>

<FormButtonContainer>
Expand All @@ -185,10 +159,10 @@ export const VerifyDomainPage = withCardStateProvider(() => {
variant='ghost'
textVariant='buttonExtraSmallBold'
type='reset'
isDisabled={status.isLoading || success}
isDisabled={otp.isLoading || otp.otpControl.otpInputProps.feedbackType === 'success'}
onClick={() => {
codeControlState.clearFeedback();
codeControl.reset();
otp.otpControl.otpInputProps.clearFeedback();
otp.otpControl.reset();
wizard.prevStep();
}}
localizationKey={localizationKeys('userProfile.formButtonReset')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
>
<Form.Root onSubmit={handleBackupCodeSubmit}>
<Form.ControlRow elementId={codeControl.id}>
<Form.Control
<Form.PlainInput
{...codeControl.props}
autoFocus
onActionClicked={onShowAlternativeMethodsClicked}
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/ui.retheme/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@ export function _SignInStart(): JSX.Element {

const identifierField = identifierAttribute === 'phone_number' ? phoneIdentifierField : textIdentifierField;

const identifierFieldRef = useRef<HTMLInputElement>(null);

const switchToNextIdentifier = () => {
setIdentifierAttribute(
i => identifierAttributes[(identifierAttributes.indexOf(i) + 1) % identifierAttributes.length],
Expand Down Expand Up @@ -273,6 +271,17 @@ export function _SignInStart(): JSX.Element {
return signInWithFields(identifierField, instantPasswordField);
};

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) {
return <LoadingCard />;
}
Expand Down Expand Up @@ -300,8 +309,7 @@ export function _SignInStart(): JSX.Element {
{standardFormAttributes.length ? (
<Form.Root onSubmit={handleFirstPartySubmit}>
<Form.ControlRow elementId={identifierField.id}>
<Form.Control
ref={identifierFieldRef}
<DynamicField
actionLabel={nextIdentifier?.action}
onActionClicked={switchToNextIdentifier}
{...identifierField.props}
Expand Down
18 changes: 7 additions & 11 deletions packages/clerk-js/src/ui.retheme/components/SignUp/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ export const SignUpForm = (props: SignUpFormProps) => {
)}
{shouldShow('phoneNumber') && (
<Form.ControlRow elementId='phoneNumber'>
<Form.Control
{...{
...formState.phoneNumber.props,
isRequired: fields.phoneNumber!.required,
isOptional: !fields.phoneNumber!.required,
}}
<Form.PhoneInput
{...formState.phoneNumber.props}
isRequired={fields.phoneNumber!.required}
isOptional={!fields.phoneNumber!.required}
actionLabel={canToggleEmailPhone ? 'Use email instead' : undefined}
onActionClicked={canToggleEmailPhone ? () => handleEmailPhoneToggle('emailAddress') : undefined}
/>
Expand All @@ -83,11 +81,9 @@ export const SignUpForm = (props: SignUpFormProps) => {
{shouldShow('password') && (
<Form.ControlRow elementId='password'>
<Form.PasswordInput
{...{
...formState.password.props,
isRequired: fields.password!.required,
isOptional: !fields.password!.required,
}}
{...formState.password.props}
isRequired={fields.password!.required}
isOptional={!fields.password!.required}
/>
</Form.ControlRow>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@ export const AddPhone = (props: AddPhoneProps) => {
>
<Form.Root onSubmit={addPhone}>
<Form.ControlRow elementId={phoneField.id}>
<Form.Control
<Form.PhoneInput
{...phoneField.props}
required
autoFocus
/>
</Form.ControlRow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,7 @@ import React from 'react';

import { useCoreUser } from '../../contexts';
import { Col, descriptors, localizationKeys } from '../../customizables';
import {
ContentPage,
FormButtonContainer,
NavigateToFlowStartButton,
useCardState,
useCodeControl,
} from '../../elements';
import { CodeForm } from '../../elements/CodeForm';
import { useLoadingStatus } from '../../hooks';
import { handleError, sleep, useFormControl } from '../../utils';
import { ContentPage, Form, FormButtonContainer, NavigateToFlowStartButton, useFieldOTP } from '../../elements';
import { UserProfileBreadcrumbs } from './UserProfileNavbar';

type VerifyTOTPProps = {
Expand All @@ -22,34 +13,19 @@ type VerifyTOTPProps = {

export const VerifyTOTP = (props: VerifyTOTPProps) => {
const { onVerified, resourceRef } = props;
const card = useCardState();
const user = useCoreUser();
const status = useLoadingStatus();
const [success, setSuccess] = React.useState(false);
const codeControlState = useFormControl('code', '');
const codeControl = useCodeControl(codeControlState);

const resolve = async (totp: TOTPResource) => {
setSuccess(true);
resourceRef.current = totp;
await sleep(750);
onVerified();
};

const reject = async (err: any) => {
handleError(err, [codeControlState], card.setError);
status.setIdle();
await sleep(750);
codeControl.reset();
};

codeControl.onCodeEntryFinished(code => {
status.setLoading();
codeControlState.setError(undefined);
return user
.verifyTOTP({ code })
.then((totp: TOTPResource) => resolve(totp))
.catch(reject);
const otp = useFieldOTP<TOTPResource>({
onCodeEntryFinished: (code, resolve, reject) => {
user
.verifyTOTP({ code })
.then((totp: TOTPResource) => resolve(totp))
.catch(reject);
},
onResolve: a => {
resourceRef.current = a;
onVerified();
},
});

return (
Expand All @@ -58,12 +34,10 @@ export const VerifyTOTP = (props: VerifyTOTPProps) => {
Breadcrumbs={UserProfileBreadcrumbs}
>
<Col>
<CodeForm
title={localizationKeys('userProfile.mfaTOTPPage.verifyTitle')}
subtitle={localizationKeys('userProfile.mfaTOTPPage.verifySubtitle')}
isLoading={status.isLoading}
success={success}
codeControl={codeControl}
<Form.OTPInput
{...otp}
label={localizationKeys('userProfile.mfaTOTPPage.verifyTitle')}
description={localizationKeys('userProfile.mfaTOTPPage.verifySubtitle')}
/>
</Col>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import type { EmailAddressResource, PhoneNumberResource } from '@clerk/types';
import React from 'react';

import { descriptors, localizationKeys } from '../../customizables';
import { FormButtonContainer, NavigateToFlowStartButton, useCardState, useCodeControl } from '../../elements';
import { CodeForm } from '../../elements/CodeForm';
import { useLoadingStatus } from '../../hooks';
import { handleError, sleep, useFormControl } from '../../utils';
import { Form, FormButtonContainer, NavigateToFlowStartButton, useCardState, useFieldOTP } from '../../elements';
import { handleError } from '../../utils';

type VerifyWithCodeProps = {
nextStep: () => void;
Expand All @@ -17,51 +15,33 @@ type VerifyWithCodeProps = {
export const VerifyWithCode = (props: VerifyWithCodeProps) => {
const card = useCardState();
const { nextStep, identification, identifier, prepareVerification } = props;
const [success, setSuccess] = React.useState(false);
const status = useLoadingStatus();
const codeControlState = useFormControl('code', '');
const codeControl = useCodeControl(codeControlState);

React.useEffect(() => {
void prepare();
}, []);

const prepare = () => {
return prepareVerification?.()?.catch(err => handleError(err, [], card.setError));
};

const resolve = async () => {
setSuccess(true);
await sleep(750);
nextStep();
};

const reject = async (err: any) => {
handleError(err, [codeControlState], card.setError);
status.setIdle();
await sleep(750);
codeControl.reset();
};

codeControl.onCodeEntryFinished(code => {
status.setLoading();
codeControlState.setError(undefined);
return identification
?.attemptVerification({ code: code })
.then(() => resolve())
.catch(reject);
const otp = useFieldOTP({
onCodeEntryFinished: (code, resolve, reject) => {
identification
?.attemptVerification({ code: code })
.then(() => resolve())
.catch(reject);
},
onResendCodeClicked: prepare,
onResolve: nextStep,
});

React.useEffect(() => {
void prepare();
}, []);

return (
<>
<CodeForm
title={localizationKeys('userProfile.emailAddressPage.emailCode.formTitle')}
subtitle={localizationKeys('userProfile.emailAddressPage.emailCode.formSubtitle', { identifier })}
<Form.OTPInput
{...otp}
label={localizationKeys('userProfile.emailAddressPage.emailCode.formTitle')}
description={localizationKeys('userProfile.emailAddressPage.emailCode.formSubtitle', { identifier })}
resendButton={localizationKeys('userProfile.emailAddressPage.emailCode.resendButton')}
codeControl={codeControl}
isLoading={status.isLoading}
success={success}
onResendCodeClicked={prepare}
/>
<FormButtonContainer>
<NavigateToFlowStartButton
Expand Down
Loading

0 comments on commit 63713c3

Please sign in to comment.