diff --git a/.changeset/bright-heads-start.md b/.changeset/bright-heads-start.md
new file mode 100644
index 00000000000..b00cace73c6
--- /dev/null
+++ b/.changeset/bright-heads-start.md
@@ -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.
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index c78aa41243d..c61b05e0018 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -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" },
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index 5ff9db21ace..367db6a1a36 100644
--- a/packages/clerk-js/src/core/resources/SignIn.ts
+++ b/packages/clerk-js/src/core/resources/SignIn.ts
@@ -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);
diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx
index d40ed41d158..e2a215891db 100644
--- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx
+++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx
@@ -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';
@@ -165,13 +165,7 @@ export function OAuthConsentInternal() {
))}
- ({
- background: 'rgba(243, 107, 22, 0.12)',
- padding: t.space.$4,
- borderRadius: t.radii.$lg,
- })}
- >
+
{''}. You may be sharing sensitive data with this site or app.
-
+
;
+ case 'email_code':
+ return (
+
+ );
+ case 'email_link':
+ return (
+
+ );
default:
return ;
}
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoAlternativeMethods.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoAlternativeMethods.tsx
index b49deeb8522..da32675dadd 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoAlternativeMethods.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoAlternativeMethods.tsx
@@ -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}"`);
}
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
index 314c63cc583..c12ac7366f1 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx
@@ -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';
@@ -17,7 +17,7 @@ import { useRouter } from '../../router';
import { isResetPasswordStrategy } from './utils';
export type SignInFactorTwoCodeCard = Pick & {
- factor: PhoneCodeFactor | TOTPFactor;
+ factor: EmailCodeFactor | PhoneCodeFactor | TOTPFactor;
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
prepare?: () => Promise;
@@ -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();
@@ -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 })
@@ -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}
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailCodeCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailCodeCard.tsx
new file mode 100644
index 00000000000..e21534cfb7c
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailCodeCard.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailLinkCard.tsx
new file mode 100644
index 00000000000..c98aced983b
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoEmailLinkCard.tsx
@@ -0,0 +1,101 @@
+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 & {
+ 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 (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/elements/TagInput.tsx b/packages/clerk-js/src/ui/elements/TagInput.tsx
index 95c60fe3adc..674a98496ce 100644
--- a/packages/clerk-js/src/ui/elements/TagInput.tsx
+++ b/packages/clerk-js/src/ui/elements/TagInput.tsx
@@ -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,
]}
diff --git a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx
index 14b338d76ec..3163ba73f2e 100644
--- a/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx
+++ b/packages/clerk-js/src/ui/elements/VerificationCodeCard.tsx
@@ -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';
@@ -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;
@@ -31,7 +32,7 @@ export type VerificationCodeCardProps = {
};
export const VerificationCodeCard = (props: PropsWithChildren) => {
- const { showAlternativeMethods = true, children } = props;
+ const { showAlternativeMethods = true, cardNotice, children } = props;
const card = useCardState();
const otp = useFieldOTP({
@@ -64,6 +65,17 @@ export const VerificationCodeCard = (props: PropsWithChildren
+
+ {cardNotice && (
+
+
+
+ )}
+