Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
341f537
feat(clerk-js, types): Add clientTrustState and untrustedFirstFactors…
chriscanin Oct 29, 2025
fb16e60
feat(clerk-js, types): Add client_trust_state and untrusted_first_fac…
chriscanin Oct 29, 2025
25fe87f
refactor(client-resource): remove client_trust_state from dummy data
chriscanin Oct 29, 2025
78041a4
feat(client-resource): update fraud protection fields in Client and S…
chriscanin Oct 29, 2025
701995a
chore: Remove untrusted_first_factors
tmilewski Oct 31, 2025
307185c
chore: Update changeset
tmilewski Nov 4, 2025
a2e2450
feat(clerk-js, types): Add clientTrustState and untrustedFirstFactors…
chriscanin Oct 29, 2025
f1e2307
chore: Remove untrusted_first_factors
tmilewski Oct 31, 2025
4381e03
feat: Prep work to support email_code as a second factor
tmilewski Oct 31, 2025
b70b5db
chore: Ensure types pull from shared pkg
tmilewski Nov 4, 2025
67ec01a
feat: SignInFactorTwoCode
tmilewski Nov 7, 2025
820d80d
feat: Email Link as Second Factor
tmilewski Nov 11, 2025
df941c2
chore: Clean up
tmilewski Nov 11, 2025
32c9066
chore: Update Bundlewatch config
tmilewski Nov 12, 2025
59d9c5b
chore: Remove test keys
tmilewski Nov 12, 2025
6af8a9f
chore: Clean up
tmilewski Nov 12, 2025
a119a90
chore: Clean up
tmilewski Nov 12, 2025
749ca74
fix: build
tmilewski Nov 12, 2025
35c2b76
chore: Remove unused prop
tmilewski Nov 12, 2025
8778783
chore: Update localization files
tmilewski Nov 12, 2025
2bcf09a
chore: Update localization files
tmilewski Nov 12, 2025
0d07fae
fix: Allow formTitle to be undefined
tmilewski Nov 12, 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
7 changes: 7 additions & 0 deletions .changeset/bright-heads-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Support for `email_code` and `email_link` as a second factor when user is signing in on a new device.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
{ "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "63KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "63.2KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" },
{ "path": "./dist/vendors*.js", "maxSize": "47KB" },
Expand Down
22 changes: 17 additions & 5 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,28 @@ export class SignIn extends BaseResource implements SignInResource {
if (!this.id) {
clerkVerifyEmailAddressCalledBeforeCreate('SignIn');
}
await this.prepareFirstFactor({

const emailLinkParams: EmailLinkConfig = {
strategy: 'email_link',
emailAddressId: emailAddressId,
redirectUrl: redirectUrl,
});
emailAddressId,
redirectUrl,
};
const isSecondFactor = this.status === 'needs_second_factor';
const verificationKey: 'firstFactorVerification' | 'secondFactorVerification' = isSecondFactor
? 'secondFactorVerification'
: 'firstFactorVerification';

if (isSecondFactor) {
await this.prepareSecondFactor(emailLinkParams);
} else {
await this.prepareFirstFactor(emailLinkParams);
}

return new Promise((resolve, reject) => {
void run(() => {
return this.reload()
.then(res => {
const status = res.firstFactorVerification.status;
const status = res[verificationKey].status;
if (status === 'verified' || status === 'expired') {
stop();
resolve(res);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Header } from '@/ui/elements/Header';
import { Modal } from '@/ui/elements/Modal';
import { Tooltip } from '@/ui/elements/Tooltip';
import { LockDottedCircle } from '@/ui/icons';
import { Textarea } from '@/ui/primitives';
import { Alert, Textarea } from '@/ui/primitives';
import type { ThemableCssProp } from '@/ui/styledSystem';
import { common } from '@/ui/styledSystem';
import { colors } from '@/ui/utils/colors';
Expand Down Expand Up @@ -165,13 +165,7 @@ export function OAuthConsentInternal() {
))}
</Box>
</Box>
<Box
sx={t => ({
background: 'rgba(243, 107, 22, 0.12)',
padding: t.space.$4,
borderRadius: t.radii.$lg,
})}
>
<Alert colorScheme='warning'>
<Text
colorScheme='warning'
variant='caption'
Expand Down Expand Up @@ -201,7 +195,7 @@ export function OAuthConsentInternal() {
</Tooltip.Root>
{''}. You may be sharing sensitive data with this site or app.
</Text>
</Box>
</Alert>
<Grid
columns={2}
gap={3}
Expand Down
25 changes: 23 additions & 2 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorTwo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../commo
import { useCoreSignIn } from '../../contexts';
import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods';
import { SignInFactorTwoBackupCodeCard } from './SignInFactorTwoBackupCodeCard';
import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard';
import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard';
import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard';
import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard';
import { determineStartingSignInSecondFactor } from './utils';
Expand Down Expand Up @@ -56,11 +58,12 @@ function SignInFactorTwoInternal(): JSX.Element {
);
}

const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor);
switch (currentFactor?.strategy) {
case 'phone_code':
return (
<SignInFactorTwoPhoneCodeCard
factorAlreadyPrepared={lastPreparedFactorKeyRef.current === factorKey(currentFactor)}
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
Expand All @@ -69,14 +72,32 @@ function SignInFactorTwoInternal(): JSX.Element {
case 'totp':
return (
<SignInFactorTwoTOTPCard
factorAlreadyPrepared={lastPreparedFactorKeyRef.current === factorKey(currentFactor)}
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
case 'backup_code':
return <SignInFactorTwoBackupCodeCard onShowAlternativeMethodsClicked={toggleAllStrategies} />;
case 'email_code':
return (
<SignInFactorTwoEmailCodeCard
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
case 'email_link':
return (
<SignInFactorTwoEmailLinkCard
factorAlreadyPrepared={factorAlreadyPrepared}
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
/>
);
default:
return <LoadingCard />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ export function getButtonLabel(factor: SignInFactor): LocalizationKey {
return localizationKeys('signIn.alternativeMethods.blockButton__totp');
case 'backup_code':
return localizationKeys('signIn.alternativeMethods.blockButton__backupCode');
case 'email_code':
return localizationKeys('signIn.alternativeMethods.blockButton__emailCode', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
});
case 'email_link':
return localizationKeys('signIn.alternativeMethods.blockButton__emailLink', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
});
default:
throw new Error(`Invalid sign in strategy: "${factor.strategy}"`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { PhoneCodeFactor, SignInResource, TOTPFactor } from '@clerk/shared/types';
import type { EmailCodeFactor, PhoneCodeFactor, SignInResource, TOTPFactor } from '@clerk/shared/types';
import React from 'react';

import { useCardState } from '@/ui/elements/contexts';
Expand All @@ -17,7 +17,7 @@ import { useRouter } from '../../router';
import { isResetPasswordStrategy } from './utils';

export type SignInFactorTwoCodeCard = Pick<VerificationCodeCardProps, 'onShowAlternativeMethodsClicked'> & {
factor: PhoneCodeFactor | TOTPFactor;
factor: EmailCodeFactor | PhoneCodeFactor | TOTPFactor;
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
prepare?: () => Promise<SignInResource>;
Expand All @@ -30,6 +30,12 @@ type SignInFactorTwoCodeFormProps = SignInFactorTwoCodeCard & {
resendButton?: LocalizationKey;
};

const isResettingPassword = (resource: SignInResource) =>
isResetPasswordStrategy(resource.firstFactorVerification?.strategy) &&
resource.firstFactorVerification?.status === 'verified';

const isNewDevice = (resource: SignInResource) => resource.clientTrustState === 'new';

export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => {
const signIn = useCoreSignIn();
const card = useCardState();
Expand Down Expand Up @@ -63,10 +69,6 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
}
: undefined;

const isResettingPassword = (resource: SignInResource) =>
isResetPasswordStrategy(resource.firstFactorVerification?.strategy) &&
resource.firstFactorVerification?.status === 'verified';

const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => {
signIn
.attemptSecondFactor({ strategy: props.factor.strategy, code })
Expand Down Expand Up @@ -105,6 +107,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
cardSubtitle={
isResettingPassword(signIn) ? localizationKeys('signIn.forgotPassword.subtitle') : props.cardSubtitle
}
cardNotice={isNewDevice(signIn) ? localizationKeys('signIn.newDeviceVerificationNotice') : undefined}
resendButton={props.resendButton}
inputLabel={props.inputLabel}
onCodeEntryFinishedAction={action}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { EmailCodeFactor } from '@clerk/shared/types';

import { useCoreSignIn } from '../../contexts';
import { Flow, localizationKeys } from '../../customizables';
import type { SignInFactorTwoCodeCard } from './SignInFactorTwoCodeForm';
import { SignInFactorTwoCodeForm } from './SignInFactorTwoCodeForm';

type SignInFactorTwoEmailCodeCardProps = SignInFactorTwoCodeCard & { factor: EmailCodeFactor };

export const SignInFactorTwoEmailCodeCard = (props: SignInFactorTwoEmailCodeCardProps) => {
const signIn = useCoreSignIn();

const prepare = () => {
const { emailAddressId, strategy } = props.factor;
return signIn.prepareSecondFactor({ emailAddressId, strategy });
};

return (
<Flow.Part part='emailCode2Fa'>
<SignInFactorTwoCodeForm
{...props}
cardTitle={localizationKeys('signIn.emailCodeMfa.title')}
cardSubtitle={localizationKeys('signIn.emailCodeMfa.subtitle')}
inputLabel={localizationKeys('signIn.emailCodeMfa.formTitle')}
resendButton={localizationKeys('signIn.emailCodeMfa.resendButton')}
prepare={prepare}
/>
</Flow.Part>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { EmailLinkFactor, SignInResource } from '@clerk/shared/types';
import React from 'react';

import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCard';
import { VerificationLinkCard } from '@/ui/elements/VerificationLinkCard';
import { handleError } from '@/ui/utils/errorHandler';

import { EmailLinkStatusCard } from '../../common';
import { buildVerificationRedirectUrl } from '../../common/redirects';
import { useCoreSignIn, useSignInContext } from '../../contexts';
import { Flow, localizationKeys, useLocalizations } from '../../customizables';
import { useCardState } from '../../elements/contexts';
import { useEmailLink } from '../../hooks/useEmailLink';

type SignInFactorTwoEmailLinkCardProps = Pick<VerificationCodeCardProps, 'onShowAlternativeMethodsClicked'> & {
factor: EmailLinkFactor;
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
};

const isNewDevice = (resource: SignInResource) => resource.clientTrustState === 'new';

export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCardProps) => {
const { t } = useLocalizations();
const card = useCardState();
const signIn = useCoreSignIn();
const signInContext = useSignInContext();
const { signInUrl } = signInContext;
const { afterSignInUrl } = useSignInContext();
const { setActive } = useClerk();
const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn);
const [showVerifyModal, setShowVerifyModal] = React.useState(false);
const clerk = useClerk();

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

const restartVerification = () => {
cancelEmailLinkFlow();
void startEmailLinkVerification();
};

const startEmailLinkVerification = () => {
startEmailLinkFlow({
emailAddressId: props.factor.emailAddressId,
redirectUrl: buildVerificationRedirectUrl({ ctx: signInContext, baseUrl: signInUrl, intent: 'sign-in' }),
})
.then(res => handleVerificationResult(res))
.catch(err => {
if (isUserLockedError(err)) {
// @ts-expect-error -- private method for the time being
return clerk.__internal_navigateWithError('..', err.errors[0]);
}

handleError(err, [], card.setError);
});
};

const handleVerificationResult = async (si: SignInResource) => {
const ver = si.secondFactorVerification;
if (ver.status === 'expired') {
card.setError(t(localizationKeys('formFieldError__verificationLinkExpired')));
} else if (ver.verifiedFromTheSameClient()) {
setShowVerifyModal(true);
} else {
await setActive({
session: si.createdSessionId,
redirectUrl: afterSignInUrl,
});
}
};

if (showVerifyModal) {
return (
<EmailLinkStatusCard
title={localizationKeys('signIn.emailLink.verifiedSwitchTab.titleNewTab')}
subtitle={localizationKeys('signIn.emailLink.verifiedSwitchTab.subtitleNewTab')}
status='verified_switch_tab'
/>
);
}

return (
<Flow.Part part='emailLink'>
<VerificationLinkCard
cardTitle={localizationKeys('signIn.emailLinkMfa.title')}
cardSubtitle={localizationKeys('signIn.emailLinkMfa.subtitle')}
cardNotice={isNewDevice(signIn) ? localizationKeys('signIn.newDeviceVerificationNotice') : undefined}
formTitle={localizationKeys('signIn.emailLinkMfa.formTitle')}
formSubtitle={localizationKeys('signIn.emailLinkMfa.formSubtitle')}
resendButton={localizationKeys('signIn.emailLinkMfa.resendButton')}
onResendCodeClicked={restartVerification}
safeIdentifier={props.factor.safeIdentifier}
profileImageUrl={signIn.userData.imageUrl}
onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked}
/>
</Flow.Part>
);
};
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/elements/TagInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const TagInput = (props: TagInputProps) => {
overflowY: 'auto',
cursor: 'text',
justifyItems: 'center',
...common.borderVariants(t).normal,
...common.borderVariants(t, { hoverStyles: true }).normal,
}),
sx,
]}
Expand Down
16 changes: 14 additions & 2 deletions packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from 'react';
import React from 'react';

import { Button, Col, descriptors, localizationKeys } from '../customizables';
import { Alert, Button, Col, descriptors, localizationKeys, Text } from '../customizables';
import type { LocalizationKey } from '../localization';
import { Card } from './Card';
import { useFieldOTP } from './CodeControl';
Expand All @@ -13,6 +13,7 @@ import { IdentityPreview } from './IdentityPreview';
export type VerificationCodeCardProps = {
cardTitle: LocalizationKey;
cardSubtitle: LocalizationKey;
cardNotice?: LocalizationKey;
inputLabel?: LocalizationKey;
safeIdentifier?: string | undefined | null;
resendButton?: LocalizationKey;
Expand All @@ -31,7 +32,7 @@ export type VerificationCodeCardProps = {
};

export const VerificationCodeCard = (props: PropsWithChildren<VerificationCodeCardProps>) => {
const { showAlternativeMethods = true, children } = props;
const { showAlternativeMethods = true, cardNotice, children } = props;
const card = useCardState();

const otp = useFieldOTP({
Expand Down Expand Up @@ -64,6 +65,17 @@ export const VerificationCodeCard = (props: PropsWithChildren<VerificationCodeCa
label={props.inputLabel}
resendButton={props.resendButton}
/>

{cardNotice && (
<Alert colorScheme='warning'>
<Text
colorScheme='warning'
localizationKey={cardNotice}
variant='caption'
/>
</Alert>
)}

<Col gap={3}>
<Button
elementDescriptor={descriptors.formButtonPrimary}
Expand Down
Loading
Loading