Skip to content

Commit 1ff6d6e

Browse files
authored
feat(clerk-js): Introduce WhatsApp as an alternative phone code provider (#5894)
1 parent 2e60b79 commit 1ff6d6e

38 files changed

+825
-210
lines changed

.changeset/cold-pianos-return.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/localizations': patch
5+
'@clerk/types': patch
6+
---
7+
8+
Introduce `WhatsApp` as an alternative channel for phone code delivery.
9+
10+
The new `channel` property accompanies the `phone_code` strategy. Possible values: `whatsapp` and `sms`.

packages/clerk-js/bundlewatch.config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "594.2kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "68.3KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "595.5kB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "68.5KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "110KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "52KB" },
7-
{ "path": "./dist/ui-common*.js", "maxSize": "104.4KB" },
7+
{ "path": "./dist/ui-common*.js", "maxSize": "105KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "39.5KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
1010
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
@@ -13,7 +13,7 @@
1313
{ "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" },
1414
{ "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" },
1515
{ "path": "./dist/signin*.js", "maxSize": "14KB" },
16-
{ "path": "./dist/signup*.js", "maxSize": "6.76KB" },
16+
{ "path": "./dist/signup*.js", "maxSize": "7.5KB" },
1717
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
1818
{ "path": "./dist/userprofile*.js", "maxSize": "16.5KB" },
1919
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export class SignIn extends BaseResource implements SignInResource {
120120
config = {
121121
phoneNumberId: factor.phoneNumberId,
122122
default: factor.default,
123+
channel: factor.channel,
123124
} as PhoneCodeConfig;
124125
break;
125126
case 'web3_metamask_signature':

packages/clerk-js/src/core/resources/UserSettings.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
OAuthStrategy,
66
PasskeySettingsData,
77
PasswordSettingsData,
8+
PhoneCodeChannel,
89
SamlSettings,
910
SignInData,
1011
SignUpData,
@@ -173,6 +174,17 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
173174
.flat() as any as Web3Strategy[];
174175
}
175176

177+
get alternativePhoneCodeChannels(): PhoneCodeChannel[] {
178+
if (!this.attributes) {
179+
return [];
180+
}
181+
182+
return Object.entries(this.attributes)
183+
.filter(([name, attr]) => attr.used_for_first_factor && name === 'phone_number')
184+
.map(([, desc]) => desc?.channels?.filter(factor => factor !== 'sms') || [])
185+
.flat() as any as PhoneCodeChannel[];
186+
}
187+
176188
public constructor(data: UserSettingsJSON | UserSettingsJSONSnapshot | null = null) {
177189
super();
178190
this.fromJSON(data);

packages/clerk-js/src/core/resources/Verification.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { errorToJSON, parseError } from '@clerk/shared/error';
22
import type {
33
ClerkAPIError,
44
PasskeyVerificationResource,
5+
PhoneCodeChannel,
56
PublicKeyCredentialCreationOptionsJSON,
67
PublicKeyCredentialCreationOptionsWithoutExtensions,
78
SignUpVerificationJSON,
@@ -32,6 +33,7 @@ export class Verification extends BaseResource implements VerificationResource {
3233
expireAt: Date | null = null;
3334
error: ClerkAPIError | null = null;
3435
verifiedAtClient: string | null = null;
36+
channel?: PhoneCodeChannel;
3537

3638
constructor(data: VerificationJSON | VerificationJSONSnapshot | null) {
3739
super();
@@ -57,6 +59,7 @@ export class Verification extends BaseResource implements VerificationResource {
5759
this.attempts = data.attempts;
5860
this.expireAt = unixEpochToDate(data.expire_at || undefined);
5961
this.error = data.error ? parseError(data.error) : null;
62+
this.channel = data.channel || undefined;
6063
}
6164
return this;
6265
}

packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ Client {
229229
"createdSessionId": null,
230230
"firstFactorVerification": Verification {
231231
"attempts": null,
232+
"channel": undefined,
232233
"error": {
233234
"code": "",
234235
"longMessage": undefined,
@@ -259,6 +260,7 @@ Client {
259260
"resetPassword": [Function],
260261
"secondFactorVerification": Verification {
261262
"attempts": null,
263+
"channel": undefined,
262264
"error": {
263265
"code": "",
264266
"longMessage": undefined,
@@ -335,6 +337,7 @@ Client {
335337
"verifications": SignUpVerifications {
336338
"emailAddress": SignUpVerification {
337339
"attempts": null,
340+
"channel": undefined,
338341
"error": {
339342
"code": "",
340343
"longMessage": undefined,
@@ -361,6 +364,7 @@ Client {
361364
},
362365
"externalAccount": Verification {
363366
"attempts": null,
367+
"channel": undefined,
364368
"error": {
365369
"code": "",
366370
"longMessage": undefined,
@@ -385,6 +389,7 @@ Client {
385389
},
386390
"phoneNumber": SignUpVerification {
387391
"attempts": null,
392+
"channel": undefined,
388393
"error": {
389394
"code": "",
390395
"longMessage": undefined,
@@ -411,6 +416,7 @@ Client {
411416
},
412417
"web3Wallet": SignUpVerification {
413418
"attempts": null,
419+
"channel": undefined,
414420
"error": {
415421
"code": "",
416422
"longMessage": undefined,

packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type { OAuthProvider, Web3Provider } from '@clerk/types';
1+
import type { OAuthProvider, PhoneCodeProvider, Web3Provider } from '@clerk/types';
22

33
import { Box, descriptors, Text } from '../customizables';
44
import type { PropsOfComponent } from '../styledSystem';
55
import { common } from '../styledSystem';
66

77
type ProviderInitialIconProps = PropsOfComponent<typeof Box> & {
88
value: string;
9-
id: Web3Provider | OAuthProvider;
9+
id: Web3Provider | OAuthProvider | PhoneCodeProvider;
1010
};
1111

1212
export const ProviderInitialIcon = (props: ProviderInitialIconProps) => {

packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
8585
<SignInSocialButtons
8686
enableWeb3Providers
8787
enableOAuthProviders
88+
enableAlternativePhoneCodeProviders={false}
8889
/>
8990
{firstPartyFactors &&
9091
firstPartyFactors.map((factor, i) => (
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { PhoneCodeChannelData } from '@clerk/types';
2+
3+
import { Button, Col, descriptors, Flex, Image, localizationKeys } from '../../customizables';
4+
import { Card, Form, Header, useCardState } from '../../elements';
5+
import { CaptchaElement } from '../../elements/CaptchaElement';
6+
import { useEnabledThirdPartyProviders } from '../../hooks';
7+
import type { FormControlState } from '../../utils';
8+
9+
type SignUpAlternativePhoneCodePhoneNumberCardProps = {
10+
handleSubmit: React.FormEventHandler;
11+
phoneNumberFormState: FormControlState<any>;
12+
onUseAnotherMethod: () => void;
13+
phoneCodeProvider: PhoneCodeChannelData;
14+
};
15+
16+
export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternativePhoneCodePhoneNumberCardProps) => {
17+
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props;
18+
const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders();
19+
const provider = phoneCodeProvider.name;
20+
const channel = phoneCodeProvider.channel;
21+
const card = useCardState();
22+
23+
return (
24+
<Card.Root>
25+
<Card.Content>
26+
<Header.Root
27+
showLogo
28+
showDivider
29+
>
30+
<Col center>
31+
<Image
32+
src={providerToDisplayData[channel]?.iconUrl}
33+
alt={`${strategyToDisplayData[channel].name} logo`}
34+
sx={theme => ({
35+
width: theme.sizes.$7,
36+
height: theme.sizes.$7,
37+
maxWidth: '100%',
38+
marginBottom: theme.sizes.$6,
39+
})}
40+
/>
41+
</Col>
42+
<Header.Title
43+
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.title', {
44+
provider,
45+
})}
46+
/>
47+
<Header.Subtitle
48+
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.subtitle', {
49+
provider,
50+
})}
51+
/>
52+
</Header.Root>
53+
<Card.Alert>{card.error}</Card.Alert>
54+
<Flex
55+
direction='col'
56+
elementDescriptor={descriptors.main}
57+
gap={6}
58+
>
59+
<Form.Root
60+
onSubmit={handleSubmit}
61+
gap={8}
62+
>
63+
<Col gap={6}>
64+
<Form.ControlRow elementId='phoneNumber'>
65+
<Form.PhoneInput
66+
{...phoneNumberFormState.props}
67+
label={localizationKeys('signIn.start.alternativePhoneCodeProvider.label', { provider })}
68+
isRequired
69+
isOptional={false}
70+
actionLabel={undefined}
71+
onActionClicked={undefined}
72+
/>
73+
</Form.ControlRow>
74+
</Col>
75+
<Col center>
76+
<CaptchaElement />
77+
<Col
78+
gap={6}
79+
sx={{
80+
width: '100%',
81+
}}
82+
>
83+
<Form.SubmitButton
84+
hasArrow
85+
localizationKey={localizationKeys('formButtonPrimary')}
86+
/>
87+
</Col>
88+
</Col>
89+
<Col center>
90+
<Button
91+
variant='link'
92+
colorScheme='neutral'
93+
onClick={onUseAnotherMethod}
94+
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.actionLink')}
95+
/>
96+
</Col>
97+
</Form.Root>
98+
</Flex>
99+
</Card.Content>
100+
</Card.Root>
101+
);
102+
};

packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ function SignInFactorOneInternal(): JSX.Element {
3737
const availableFactors = signIn.supportedFirstFactors;
3838
const router = useRouter();
3939
const card = useCardState();
40-
const { supportedFirstFactors } = useCoreSignIn();
40+
const { supportedFirstFactors, firstFactorVerification } = useCoreSignIn();
41+
42+
const alternativePhoneCodeChannel = firstFactorVerification.channel;
4143

4244
const lastPreparedFactorKeyRef = React.useRef('');
4345
const [{ currentFactor }, setFactor] = React.useState<{
@@ -157,7 +159,7 @@ function SignInFactorOneInternal(): JSX.Element {
157159
<SignInFactorOnePhoneCodeCard
158160
factorAlreadyPrepared={lastPreparedFactorKeyRef.current === factorKey(currentFactor)}
159161
onFactorPrepare={handleFactorPrepare}
160-
factor={currentFactor}
162+
factor={{ ...currentFactor, channel: alternativePhoneCodeChannel }}
161163
onShowAlternativeMethodsClicked={toggleAllStrategies}
162164
/>
163165
);

packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
3838
const clerk = useClerk();
3939

4040
const shouldAvoidPrepare = signIn.firstFactorVerification.status === 'verified' && props.factorAlreadyPrepared;
41+
const isAlternativePhoneCodeProvider =
42+
props.factor.strategy === 'phone_code' ? !!props.factor.channel && props.factor.channel !== 'sms' : false;
4143

4244
const goBack = () => {
4345
return navigate('../');
@@ -55,7 +57,9 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
5557
};
5658

5759
useFetch(
58-
shouldAvoidPrepare
60+
// If an alternative phone code provider is used, we skip the prepare step
61+
// because the verification is already created on the Start screen
62+
shouldAvoidPrepare || isAlternativePhoneCodeProvider
5963
? undefined
6064
: () =>
6165
signIn
@@ -109,7 +113,9 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
109113
onResendCodeClicked={prepare}
110114
safeIdentifier={props.factor.safeIdentifier}
111115
profileImageUrl={signIn.userData.imageUrl}
112-
onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked}
116+
// if the factor is an alternative phone code provider, we don't want to show the alternative methods
117+
// instead we want to go back to the start screen
118+
onShowAlternativeMethodsClicked={isAlternativePhoneCodeProvider ? goBack : props.onShowAlternativeMethodsClicked}
113119
showAlternativeMethods={props.showAlternativeMethods}
114120
onIdentityPreviewEditClicked={goBack}
115121
onBackLinkClicked={props.onBackLinkClicked}

packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePhoneCodeCard.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
1+
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
12
import type { PhoneCodeFactor } from '@clerk/types';
23

3-
import { useEnvironment } from '../../contexts';
44
import { Flow, localizationKeys } from '../../customizables';
55
import type { SignInFactorOneCodeCard } from './SignInFactorOneCodeForm';
66
import { SignInFactorOneCodeForm } from './SignInFactorOneCodeForm';
77

88
type SignInFactorOnePhoneCodeCardProps = SignInFactorOneCodeCard & { factor: PhoneCodeFactor };
99

1010
export const SignInFactorOnePhoneCodeCard = (props: SignInFactorOnePhoneCodeCardProps) => {
11-
const { applicationName } = useEnvironment().displayConfig;
11+
const { factor } = props;
12+
const { channel } = factor;
13+
14+
let cardTitle = localizationKeys('signIn.phoneCode.title');
15+
let cardSubtitle = localizationKeys('signIn.phoneCode.subtitle');
16+
let inputLabel = localizationKeys('signIn.phoneCode.formTitle');
17+
let resendButton = localizationKeys('signIn.phoneCode.resendButton');
18+
if (channel && channel !== 'sms') {
19+
cardTitle = localizationKeys('signIn.alternativePhoneCodeProvider.title', {
20+
provider: getAlternativePhoneCodeProviderData(channel)?.name,
21+
});
22+
cardSubtitle = localizationKeys('signIn.alternativePhoneCodeProvider.subtitle');
23+
inputLabel = localizationKeys('signIn.alternativePhoneCodeProvider.formTitle');
24+
resendButton = localizationKeys('signIn.alternativePhoneCodeProvider.resendButton');
25+
}
1226

1327
return (
1428
<Flow.Part part='phoneCode'>
1529
<SignInFactorOneCodeForm
1630
{...props}
17-
cardTitle={localizationKeys('signIn.phoneCode.title')}
18-
cardSubtitle={localizationKeys('signIn.phoneCode.subtitle', { applicationName })}
19-
inputLabel={localizationKeys('signIn.phoneCode.formTitle')}
20-
resendButton={localizationKeys('signIn.phoneCode.resendButton')}
31+
cardTitle={cardTitle}
32+
cardSubtitle={cardSubtitle}
33+
inputLabel={inputLabel}
34+
resendButton={resendButton}
2135
/>
2236
</Flow.Part>
2337
);

0 commit comments

Comments
 (0)