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 && ( + + + + )} +