Skip to content

feat(clerk-js): Introduce WhatsApp as an alternative phone code provider #5894

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2396ab8
feat(clerk-js): Introduce `whatsapp` channel on #
anagstef May 9, 2025
d472810
Fix bug on providerToDisplayData and strategyToDisplayData spread
anagstef May 9, 2025
d3fa3d4
Implement the # flow
anagstef May 9, 2025
d198670
Fix navigation
anagstef May 9, 2025
87440e4
Fix factor-one navigation
anagstef May 9, 2025
df89f3c
Send the strategy and the channel on alternative phone code provider
anagstef May 12, 2025
16a2d8b
Remove the `/whatsapp` routes from the # flow
anagstef May 12, 2025
e15d954
Remove the `/whatsapp` routes from the # flow
anagstef May 12, 2025
e04c013
Remove `channel` from backend package
anagstef May 12, 2025
12205d7
Fix combined flow
anagstef May 12, 2025
a41e508
Update snapshot
anagstef May 12, 2025
b360818
Merge branch 'main' into stefanos/fraud-696-fe-implement-whatsapp-cha…
anagstef May 12, 2025
8a9de12
Increase bundlewatch limits
anagstef May 12, 2025
2540a25
Update changeset
anagstef May 12, 2025
a3a12f1
Add comments
anagstef May 13, 2025
e11006f
Merge branch 'main' into stefanos/fraud-696-fe-implement-whatsapp-cha…
anagstef May 13, 2025
b10daa0
Fix preparing twice on alternative phone code provider
anagstef May 13, 2025
8dca09b
Attempt to fix prepare_verification being fired twice
anagstef May 13, 2025
5256797
Fix phone code provider logo spacing
anagstef May 13, 2025
417bc66
Merge branch 'main' into stefanos/fraud-696-fe-implement-whatsapp-cha…
anagstef May 14, 2025
9592e76
Do not show alternative phone code providers on AlternativeMethods sc…
anagstef May 14, 2025
c16a0d1
Go to the starting screen if Use Alternative Method is clicked when o…
anagstef May 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cold-pianos-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/localizations': patch
'@clerk/types': patch
---

Introduce `WhatsApp` as an alternative channel for phone code delivery.

The new `channel` property accompanies the `phone_code` strategy. Possible values: `whatsapp` and `sms`.
8 changes: 4 additions & 4 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "594.2kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "68.3KB" },
{ "path": "./dist/clerk.js", "maxSize": "595.5kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "68.5KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "110KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "52KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "104.4KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "105KB" },
{ "path": "./dist/vendors*.js", "maxSize": "39.5KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
Expand All @@ -13,7 +13,7 @@
{ "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" },
{ "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" },
{ "path": "./dist/signin*.js", "maxSize": "14KB" },
{ "path": "./dist/#*.js", "maxSize": "6.76KB" },
{ "path": "./dist/#*.js", "maxSize": "7.5KB" },
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16.5KB" },
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class SignIn extends BaseResource implements SignInResource {
config = {
phoneNumberId: factor.phoneNumberId,
default: factor.default,
channel: factor.channel,
} as PhoneCodeConfig;
break;
case 'web3_metamask_signature':
Expand Down
12 changes: 12 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
OAuthStrategy,
PasskeySettingsData,
PasswordSettingsData,
PhoneCodeChannel,
SamlSettings,
SignInData,
#Data,
Expand Down Expand Up @@ -173,6 +174,17 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
.flat() as any as Web3Strategy[];
}

get alternativePhoneCodeChannels(): PhoneCodeChannel[] {
if (!this.attributes) {
return [];
}

return Object.entries(this.attributes)
.filter(([name, attr]) => attr.used_for_first_factor && name === 'phone_number')
.map(([, desc]) => desc?.channels?.filter(factor => factor !== 'sms') || [])
.flat() as any as PhoneCodeChannel[];
}

public constructor(data: UserSettingsJSON | UserSettingsJSONSnapshot | null = null) {
super();
this.fromJSON(data);
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/Verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { errorToJSON, parseError } from '@clerk/shared/error';
import type {
ClerkAPIError,
PasskeyVerificationResource,
PhoneCodeChannel,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialCreationOptionsWithoutExtensions,
#VerificationJSON,
Expand Down Expand Up @@ -32,6 +33,7 @@ export class Verification extends BaseResource implements VerificationResource {
expireAt: Date | null = null;
error: ClerkAPIError | null = null;
verifiedAtClient: string | null = null;
channel?: PhoneCodeChannel;

constructor(data: VerificationJSON | VerificationJSONSnapshot | null) {
super();
Expand All @@ -57,6 +59,7 @@ export class Verification extends BaseResource implements VerificationResource {
this.attempts = data.attempts;
this.expireAt = unixEpochToDate(data.expire_at || undefined);
this.error = data.error ? parseError(data.error) : null;
this.channel = data.channel || undefined;
}
return this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ Client {
"createdSessionId": null,
"firstFactorVerification": Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand Down Expand Up @@ -259,6 +260,7 @@ Client {
"resetPassword": [Function],
"secondFactorVerification": Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand Down Expand Up @@ -335,6 +337,7 @@ Client {
"verifications": #Verifications {
"emailAddress": #Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand All @@ -361,6 +364,7 @@ Client {
},
"externalAccount": Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand All @@ -385,6 +389,7 @@ Client {
},
"phoneNumber": #Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand All @@ -411,6 +416,7 @@ Client {
},
"web3Wallet": #Verification {
"attempts": null,
"channel": undefined,
"error": {
"code": "",
"longMessage": undefined,
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { OAuthProvider, Web3Provider } from '@clerk/types';
import type { OAuthProvider, PhoneCodeProvider, Web3Provider } from '@clerk/types';

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

type ProviderInitialIconProps = PropsOfComponent<typeof Box> & {
value: string;
id: Web3Provider | OAuthProvider;
id: Web3Provider | OAuthProvider | PhoneCodeProvider;
};

export const ProviderInitialIcon = (props: ProviderInitialIconProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
<SignInSocialButtons
enableWeb3Providers
enableOAuthProviders
enableAlternativePhoneCodeProviders={false}
/>
{firstPartyFactors &&
firstPartyFactors.map((factor, i) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { PhoneCodeChannelData } from '@clerk/types';

import { Button, Col, descriptors, Flex, Image, localizationKeys } from '../../customizables';
import { Card, Form, Header, useCardState } from '../../elements';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { useEnabledThirdPartyProviders } from '../../hooks';
import type { FormControlState } from '../../utils';

type #AlternativePhoneCodePhoneNumberCardProps = {
handleSubmit: React.FormEventHandler;
phoneNumberFormState: FormControlState<any>;
onUseAnotherMethod: () => void;
phoneCodeProvider: PhoneCodeChannelData;
};

export const SignInAlternativePhoneCodePhoneNumberCard = (props: #AlternativePhoneCodePhoneNumberCardProps) => {
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props;
const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders();
const provider = phoneCodeProvider.name;
const channel = phoneCodeProvider.channel;
const card = useCardState();

return (
<Card.Root>
<Card.Content>
<Header.Root
showLogo
showDivider
>
<Col center>
<Image
src={providerToDisplayData[channel]?.iconUrl}
alt={`${strategyToDisplayData[channel].name} logo`}
sx={theme => ({
width: theme.sizes.$7,
height: theme.sizes.$7,
maxWidth: '100%',
marginBottom: theme.sizes.$6,
})}
/>
</Col>
<Header.Title
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.title', {
provider,
})}
/>
<Header.Subtitle
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.subtitle', {
provider,
})}
/>
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
<Flex
direction='col'
elementDescriptor={descriptors.main}
gap={6}
>
<Form.Root
onSubmit={handleSubmit}
gap={8}
>
<Col gap={6}>
<Form.ControlRow elementId='phoneNumber'>
<Form.PhoneInput
{...phoneNumberFormState.props}
label={localizationKeys('signIn.start.alternativePhoneCodeProvider.label', { provider })}
isRequired
isOptional={false}
actionLabel={undefined}
onActionClicked={undefined}
/>
</Form.ControlRow>
</Col>
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
width: '100%',
}}
>
<Form.SubmitButton
hasArrow
localizationKey={localizationKeys('formButtonPrimary')}
/>
</Col>
</Col>
<Col center>
<Button
variant='link'
colorScheme='neutral'
onClick={onUseAnotherMethod}
localizationKey={localizationKeys('signIn.start.alternativePhoneCodeProvider.actionLink')}
/>
</Col>
</Form.Root>
</Flex>
</Card.Content>
</Card.Root>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ function SignInFactorOneInternal(): JSX.Element {
const availableFactors = signIn.supportedFirstFactors;
const router = useRouter();
const card = useCardState();
const { supportedFirstFactors } = useCoreSignIn();
const { supportedFirstFactors, firstFactorVerification } = useCoreSignIn();

const alternativePhoneCodeChannel = firstFactorVerification.channel;

const lastPreparedFactorKeyRef = React.useRef('');
const [{ currentFactor }, setFactor] = React.useState<{
Expand Down Expand Up @@ -157,7 +159,7 @@ function SignInFactorOneInternal(): JSX.Element {
<SignInFactorOnePhoneCodeCard
factorAlreadyPrepared={lastPreparedFactorKeyRef.current === factorKey(currentFactor)}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
factor={{ ...currentFactor, channel: alternativePhoneCodeChannel }}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
const clerk = useClerk();

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

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

useFetch(
shouldAvoidPrepare
// If an alternative phone code provider is used, we skip the prepare step
// because the verification is already created on the Start screen
shouldAvoidPrepare || isAlternativePhoneCodeProvider
? undefined
: () =>
signIn
Expand Down Expand Up @@ -109,7 +113,9 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
onResendCodeClicked={prepare}
safeIdentifier={props.factor.safeIdentifier}
profileImageUrl={signIn.userData.imageUrl}
onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked}
// if the factor is an alternative phone code provider, we don't want to show the alternative methods
// instead we want to go back to the start screen
onShowAlternativeMethodsClicked={isAlternativePhoneCodeProvider ? goBack : props.onShowAlternativeMethodsClicked}
showAlternativeMethods={props.showAlternativeMethods}
onIdentityPreviewEditClicked={goBack}
onBackLinkClicked={props.onBackLinkClicked}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
import type { PhoneCodeFactor } from '@clerk/types';

import { useEnvironment } from '../../contexts';
import { Flow, localizationKeys } from '../../customizables';
import type { SignInFactorOneCodeCard } from './SignInFactorOneCodeForm';
import { SignInFactorOneCodeForm } from './SignInFactorOneCodeForm';

type SignInFactorOnePhoneCodeCardProps = SignInFactorOneCodeCard & { factor: PhoneCodeFactor };

export const SignInFactorOnePhoneCodeCard = (props: SignInFactorOnePhoneCodeCardProps) => {
const { applicationName } = useEnvironment().displayConfig;
const { factor } = props;
const { channel } = factor;

let cardTitle = localizationKeys('signIn.phoneCode.title');
let cardSubtitle = localizationKeys('signIn.phoneCode.subtitle');
let inputLabel = localizationKeys('signIn.phoneCode.formTitle');
let resendButton = localizationKeys('signIn.phoneCode.resendButton');
if (channel && channel !== 'sms') {
cardTitle = localizationKeys('signIn.alternativePhoneCodeProvider.title', {
provider: getAlternativePhoneCodeProviderData(channel)?.name,
});
cardSubtitle = localizationKeys('signIn.alternativePhoneCodeProvider.subtitle');
inputLabel = localizationKeys('signIn.alternativePhoneCodeProvider.formTitle');
resendButton = localizationKeys('signIn.alternativePhoneCodeProvider.resendButton');
}

return (
<Flow.Part part='phoneCode'>
<SignInFactorOneCodeForm
{...props}
cardTitle={localizationKeys('signIn.phoneCode.title')}
cardSubtitle={localizationKeys('signIn.phoneCode.subtitle', { applicationName })}
inputLabel={localizationKeys('signIn.phoneCode.formTitle')}
resendButton={localizationKeys('signIn.phoneCode.resendButton')}
cardTitle={cardTitle}
cardSubtitle={cardSubtitle}
inputLabel={inputLabel}
resendButton={resendButton}
/>
</Flow.Part>
);
Expand Down
Loading