diff --git a/.changeset/email-link-session-reverification.md b/.changeset/email-link-session-reverification.md new file mode 100644 index 00000000000..9e2ba6502fe --- /dev/null +++ b/.changeset/email-link-session-reverification.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': patch +'@clerk/clerk-js': patch +'@clerk/ui': patch +'@clerk/localizations': patch +--- + +Support email-link first factors in session reverification. The original tab waits for the link callback, then resumes the protected action without changing the configured authentication strategy. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 981a30a6f6c..bea8b11c493 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -13,13 +13,16 @@ import { serializePublicKeyCredentialAssertion, webAuthnGetCredential as webAuthnGetCredentialOnWindow, } from '@clerk/shared/internal/clerk-js/passkeys'; +import { Poller } from '@clerk/shared/poller'; import { retry } from '@clerk/shared/retry'; import type { ActClaim, AgentActClaim, CheckAuthorization, ClientResource, + CreateEmailLinkFlowReturn, EmailCodeConfig, + EmailLinkConfig, EnterpriseSSOConfig, GetToken, GetTokenOptions, @@ -27,6 +30,7 @@ import type { SessionJSON, SessionJSONSnapshot, SessionResource, + SessionStartEmailLinkFlowParams, SessionStatus, SessionTask, SessionTouchParams, @@ -261,6 +265,12 @@ export class Session extends BaseResource implements SessionResource { case 'email_code': config = { emailAddressId: factor.emailAddressId } as EmailCodeConfig; break; + case 'email_link': + config = { + emailAddressId: factor.emailAddressId, + redirectUrl: factor.redirectUrl, + } as EmailLinkConfig; + break; case 'phone_code': config = { phoneNumberId: factor.phoneNumberId, @@ -295,6 +305,53 @@ export class Session extends BaseResource implements SessionResource { return new SessionVerification(json); }; + createEmailLinkFlow = (): CreateEmailLinkFlowReturn => { + const { run, stop } = Poller(); + + const startEmailLinkFlow = async ({ + emailAddressId, + redirectUrl, + }: SessionStartEmailLinkFlowParams): Promise => { + await this.prepareFirstFactorVerification({ strategy: 'email_link', emailAddressId, redirectUrl }); + + return new Promise((resolve, reject) => { + void run(() => { + return this.#readVerification() + .then(res => { + const verificationStatus = res.firstFactorVerification.status; + if ( + res.status === 'complete' || + res.status === 'needs_second_factor' || + verificationStatus === 'verified' || + verificationStatus === 'expired' || + verificationStatus === 'failed' + ) { + stop(); + resolve(res); + } + }) + .catch(err => { + stop(); + reject(err); + }); + }); + }); + }; + + return { startEmailLinkFlow, cancelEmailLinkFlow: stop }; + }; + + #readVerification = async (): Promise => { + const json = ( + await BaseResource._fetch({ + method: 'GET', + path: `/client/sessions/${this.id}/verify`, + }) + )?.response as unknown as SessionVerificationJSON; + + return new SessionVerification(json); + }; + attemptFirstFactorVerification = async ( attemptFactor: SessionVerifyAttemptFirstFactorParams, ): Promise => { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 33ce91597e1..452ea43e5d2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2,7 +2,7 @@ import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { clerkMock, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; +import { clerkMock, createSession, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; import { restoreDocument, setDocument, @@ -2522,4 +2522,55 @@ describe('Session', () => { }); }); }); + + describe('createEmailLinkFlow()', () => { + it('prepares email-link reverification and resolves after the callback completes the active step-up', async () => { + BaseResource.clerk = clerkMock(); + const sessionJSON = createSession({ id: 'session_1', factor_verification_age: [99999, -1] }); + const session = new Session(sessionJSON); + const fetchSpy = vi.spyOn(BaseResource, '_fetch'); + const response = (status: 'needs_first_factor' | 'complete', verificationStatus: 'unverified' | 'verified') => ({ + response: { + object: 'session_verification', + status, + level: 'first_factor', + session: sessionJSON, + first_factor_verification: { + object: 'verification_email_link', + strategy: 'email_link', + status: verificationStatus, + }, + second_factor_verification: null, + supported_first_factors: status === 'complete' ? null : [{ strategy: 'email_link' }], + supported_second_factors: null, + }, + }); + + fetchSpy + .mockResolvedValueOnce(response('needs_first_factor', 'unverified') as any) + .mockResolvedValueOnce(response('complete', 'verified') as any); + + const { startEmailLinkFlow } = session.createEmailLinkFlow(); + const result = await startEmailLinkFlow({ + emailAddressId: 'idn_email', + redirectUrl: 'https://app.example.com/protected-action', + }); + + expect(result.status).toBe('complete'); + expect(result.firstFactorVerification.strategy).toBe('email_link'); + expect(fetchSpy).toHaveBeenNthCalledWith(1, { + method: 'POST', + path: '/client/sessions/session_1/verify/prepare_first_factor', + body: { + emailAddressId: 'idn_email', + redirectUrl: 'https://app.example.com/protected-action', + strategy: 'email_link', + }, + }); + expect(fetchSpy).toHaveBeenNthCalledWith(2, { + method: 'GET', + path: '/client/sessions/session_1/verify', + }); + }); + }); }); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index c8573e68e7b..c5e6243076b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1286,6 +1286,7 @@ export const enUS: LocalizationResource = { actionText: 'Don’t have any of these?', blockButton__backupCode: 'Use a backup code', blockButton__emailCode: 'Email code to {{identifier}}', + blockButton__emailLink: 'Email link to {{identifier}}', blockButton__passkey: 'Use your passkey', blockButton__password: 'Continue with your password', blockButton__phoneCode: 'Send SMS code to {{identifier}}', @@ -1309,6 +1310,29 @@ export const enUS: LocalizationResource = { subtitle: 'Enter the code sent to your email to continue', title: 'Verification required', }, + emailLink: { + clientMismatch: { + subtitle: 'Open the link in the same browser where you started verification.', + title: 'Verification link is invalid for this browser', + }, + expired: { + subtitle: 'Return to the original tab and request a new link.', + title: 'This verification link has expired', + }, + failed: { + subtitle: 'Return to the original tab and request a new link.', + title: 'This verification link is invalid', + }, + formSubtitle: 'Use the verification link sent to your email', + formTitle: 'Verification link', + resendButton: "Didn't receive a link? Resend", + subtitle: 'We sent a verification link to your email address', + title: 'Check your email', + verified: { + subtitle: 'Return to the original tab to continue.', + title: 'Verification complete', + }, + }, noAvailableMethods: { message: 'Cannot proceed with verification. No suitable authentication factor is configured', subtitle: 'An error occurred', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 1944631d919..e6f8d0dae25 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -642,6 +642,29 @@ export type __internal_LocalizationResource = { formTitle: LocalizationValue; resendButton: LocalizationValue; }; + emailLink: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + verified: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + expired: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + failed: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + clientMismatch: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + }; phoneCode: { title: LocalizationValue; subtitle: LocalizationValue; @@ -674,6 +697,7 @@ export type __internal_LocalizationResource = { actionLink: LocalizationValue; actionText: LocalizationValue; blockButton__emailCode: LocalizationValue<'identifier'>; + blockButton__emailLink: LocalizationValue<'identifier'>; blockButton__phoneCode: LocalizationValue<'identifier'>; blockButton__password: LocalizationValue; blockButton__totp: LocalizationValue; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 878fd6e8ecb..8a8fa79425a 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -3,6 +3,7 @@ import type { BackupCodeAttempt, EmailCodeAttempt, EmailCodeConfig, + EmailLinkConfig, EnterpriseSSOConfig, PasskeyAttempt, PassKeyConfig, @@ -29,6 +30,7 @@ import type { SessionJSONSnapshot } from './snapshots'; import type { TokenResource } from './token'; import type { UserResource } from './user'; import type { Autocomplete } from './utils'; +import type { CreateEmailLinkFlowReturn } from './verification'; /** * @inline @@ -317,6 +319,10 @@ export interface SessionResource extends ClerkResource { prepareFirstFactorVerification: ( factor: SessionVerifyPrepareFirstFactorParams, ) => Promise; + /** + * Creates an email-link reverification flow. The returned promise resolves in the original tab after the link callback completes the active session verification. + */ + createEmailLinkFlow: () => CreateEmailLinkFlowReturn; /** * Attempts to complete the [first factor verification](!first-factor-verification) process. * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. @@ -527,6 +533,7 @@ export type SessionVerifyCreateParams = { export type SessionVerifyPrepareFirstFactorParams = | EmailCodeConfig + | EmailLinkConfig | PhoneCodeConfig | PassKeyConfig /** @@ -534,6 +541,11 @@ export type SessionVerifyPrepareFirstFactorParams = */ | Omit; +export type SessionStartEmailLinkFlowParams = { + emailAddressId: string; + redirectUrl: string; +}; + export type SessionVerifyAttemptFirstFactorParams = | EmailCodeAttempt | PhoneCodeAttempt diff --git a/packages/shared/src/types/sessionVerification.ts b/packages/shared/src/types/sessionVerification.ts index 61af637ce2b..d4f2929ca1d 100644 --- a/packages/shared/src/types/sessionVerification.ts +++ b/packages/shared/src/types/sessionVerification.ts @@ -1,6 +1,7 @@ import type { BackupCodeFactor, EmailCodeFactor, + EmailLinkFactor, EnterpriseSSOFactor, PasskeyFactor, PasswordFactor, @@ -52,6 +53,7 @@ export type SessionVerificationAfterMinutes = number; export type SessionVerificationFirstFactor = | EmailCodeFactor + | EmailLinkFactor | PhoneCodeFactor | PasswordFactor | PasskeyFactor diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index ef830af901b..0baafe61e1c 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -292,6 +292,7 @@ const componentNodes = Object.freeze({ SignUp: 'signUpModal', SignIn: 'signInModal', UserProfile: 'userProfileModal', + UserVerification: 'userVerificationModal', OrganizationProfile: 'organizationProfileModal', CreateOrganization: 'createOrganizationModal', Waitlist: 'waitlistModal', diff --git a/packages/ui/src/components/UserVerification/AlternativeMethods.tsx b/packages/ui/src/components/UserVerification/AlternativeMethods.tsx index 6f47ecef5ec..6cbba5ba79b 100644 --- a/packages/ui/src/components/UserVerification/AlternativeMethods.tsx +++ b/packages/ui/src/components/UserVerification/AlternativeMethods.tsx @@ -109,6 +109,10 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza return localizationKeys('reverification.alternativeMethods.blockButton__emailCode', { identifier: formatSafeIdentifier(factor.safeIdentifier) || '', }); + case 'email_link': + return localizationKeys('reverification.alternativeMethods.blockButton__emailLink', { + identifier: formatSafeIdentifier(factor.safeIdentifier) || '', + }); case 'phone_code': return localizationKeys('reverification.alternativeMethods.blockButton__phoneCode', { identifier: formatSafeIdentifier(factor.safeIdentifier) || '', @@ -125,6 +129,7 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza export function getButtonIcon(factor: SessionVerificationFirstFactor) { const icons = { email_code: Envelope, + email_link: Envelope, phone_code: SpeechBubble, password: Lock, passkey: Fingerprint, diff --git a/packages/ui/src/components/UserVerification/UVFactorOneEmailLinkCard.tsx b/packages/ui/src/components/UserVerification/UVFactorOneEmailLinkCard.tsx new file mode 100644 index 00000000000..e06bea8a1d0 --- /dev/null +++ b/packages/ui/src/components/UserVerification/UVFactorOneEmailLinkCard.tsx @@ -0,0 +1,80 @@ +import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; +import { useSession } from '@clerk/shared/react'; +import type { EmailLinkFactor } 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 { Flow, localizationKeys, useLocalizations } from '../../customizables'; +import { useCardState } from '../../elements/contexts'; +import { useAfterVerification } from './use-after-verification'; + +type UVFactorOneEmailLinkCardProps = Pick & { + factor: EmailLinkFactor; + showAlternativeMethods: boolean; +}; + +export const UVFactorOneEmailLinkCard = (props: UVFactorOneEmailLinkCardProps) => { + const { session } = useSession(); + const { t } = useLocalizations(); + const card = useCardState(); + const { handleVerificationResponse } = useAfterVerification(); + const emailLinkFlow = React.useMemo(() => session?.createEmailLinkFlow(), [session]); + + const startVerification = () => { + if (!emailLinkFlow) { + return; + } + const redirectUrl = appendModalState({ + url: window.location.href, + componentName: 'UserVerification', + startPath: '/user-verification', + currentPath: '/verify', + }); + + emailLinkFlow + .startEmailLinkFlow({ emailAddressId: props.factor.emailAddressId, redirectUrl }) + .then(result => { + if (result.firstFactorVerification.status === 'expired') { + card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); + return; + } + return handleVerificationResponse(result); + }) + .catch(err => handleError(err, [], card.setError)); + }; + + React.useEffect(() => { + void startVerification(); + return emailLinkFlow?.cancelEmailLinkFlow; + // The flow is tied to the mounted factor card. Factor changes remount this card. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const restartVerification = () => { + emailLinkFlow?.cancelEmailLinkFlow(); + card.setError(undefined); + void startVerification(); + }; + + return ( + + + + ); +}; diff --git a/packages/ui/src/components/UserVerification/UserVerificationEmailLinkVerify.tsx b/packages/ui/src/components/UserVerification/UserVerificationEmailLinkVerify.tsx new file mode 100644 index 00000000000..b3d24e5de5e --- /dev/null +++ b/packages/ui/src/components/UserVerification/UserVerificationEmailLinkVerify.tsx @@ -0,0 +1,41 @@ +import { getClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; + +import { EmailLinkStatusCard } from '../../common'; +import type { EmailLinkUIStatus } from '../../common/EmailLinkStatusCard'; +import { localizationKeys } from '../../customizables'; +import { withCardStateProvider } from '../../elements/contexts'; + +const supportedStatuses = new Set(['verified', 'expired', 'failed', 'client_mismatch']); + +const texts = { + verified: { + title: localizationKeys('reverification.emailLink.verified.title'), + subtitle: localizationKeys('reverification.emailLink.verified.subtitle'), + }, + expired: { + title: localizationKeys('reverification.emailLink.expired.title'), + subtitle: localizationKeys('reverification.emailLink.expired.subtitle'), + }, + failed: { + title: localizationKeys('reverification.emailLink.failed.title'), + subtitle: localizationKeys('reverification.emailLink.failed.subtitle'), + }, + client_mismatch: { + title: localizationKeys('reverification.emailLink.clientMismatch.title'), + subtitle: localizationKeys('reverification.emailLink.clientMismatch.subtitle'), + }, +} as const; + +export const UserVerificationEmailLinkVerify = withCardStateProvider(() => { + const queryStatus = getClerkQueryParam('__clerk_status') as EmailLinkUIStatus | null; + const status = queryStatus && supportedStatuses.has(queryStatus) ? queryStatus : 'failed'; + const text = texts[status as keyof typeof texts]; + + return ( + + ); +}); diff --git a/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx b/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx index 14bc9867c0e..0346a517aa1 100644 --- a/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx +++ b/packages/ui/src/components/UserVerification/UserVerificationFactorOne.tsx @@ -15,6 +15,7 @@ import { UserVerificationFactorOnePasswordCard } from './UserVerificationFactorO import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession'; import { sortByPrimaryFactor } from './utils'; import { UVFactorOneEmailCodeCard } from './UVFactorOneEmailCodeCard'; +import { UVFactorOneEmailLinkCard } from './UVFactorOneEmailLinkCard'; import { UVFactorOnePasskeysCard } from './UVFactorOnePasskeysCard'; import { UVFactorOnePhoneCodeCard } from './UVFactorOnePhoneCodeCard'; @@ -35,6 +36,7 @@ const factorKey = (factor: SignInFactor | null | undefined) => { const SUPPORTED_STRATEGIES: SessionVerificationFirstFactor['strategy'][] = [ 'password', 'email_code', + 'email_link', 'phone_code', 'passkey', ] as const; @@ -143,6 +145,14 @@ export function UserVerificationFactorOneInternal(): JSX.Element | null { showAlternativeMethods={hasFirstParty} /> ); + case 'email_link': + return ( + + ); case 'phone_code': return ( { expect(fixtures.session?.prepareFirstFactorVerification).toHaveBeenCalledOnce(); }); + it('prepares email-link reverification and preserves the protected action URL', async () => { + window.history.replaceState({}, '', '/account/billing?return=plans'); + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + const startEmailLinkFlow = vi.fn().mockResolvedValue({ + status: 'complete', + session: { id: 'session_1' }, + firstFactorVerification: { status: 'verified' }, + }); + fixtures.session?.startVerification.mockResolvedValue({ + status: 'needs_first_factor', + supportedFirstFactors: [ + { + strategy: 'email_link', + emailAddressId: 'idn_email', + safeIdentifier: 'user@example.com', + }, + ], + }); + fixtures.session?.createEmailLinkFlow.mockReturnValue({ + startEmailLinkFlow, + cancelEmailLinkFlow: vi.fn(), + }); + + const { getByText } = render(, { wrapper }); + await waitFor(() => getByText('Check your email')); + await waitFor(() => expect(startEmailLinkFlow).toHaveBeenCalledOnce()); + + const redirectUrl = new URL(startEmailLinkFlow.mock.calls[0][0].redirectUrl); + expect(redirectUrl.pathname).toBe('/account/billing'); + expect(redirectUrl.searchParams.get('return')).toBe('plans'); + const modalState = JSON.parse(atob(redirectUrl.searchParams.get('__clerk_modal_state')!)); + expect(modalState).toMatchObject({ + componentName: 'UserVerification', + path: '/verify', + startPath: '/user-verification', + }); + await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalledWith({ session: 'session_1' })); + }); + describe('Submitting', () => { it('navigates to UserVerificationFactorTwo page when user submits first factor and second factor is enabled', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/UserVerification/__tests__/UserVerificationEmailLinkVerify.test.tsx b/packages/ui/src/components/UserVerification/__tests__/UserVerificationEmailLinkVerify.test.tsx new file mode 100644 index 00000000000..63043451757 --- /dev/null +++ b/packages/ui/src/components/UserVerification/__tests__/UserVerificationEmailLinkVerify.test.tsx @@ -0,0 +1,26 @@ +import { afterEach, describe, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { UserVerificationEmailLinkVerify } from '../UserVerificationEmailLinkVerify'; + +const { createFixtures } = bindCreateFixtures('UserVerification'); + +describe('UserVerificationEmailLinkVerify', () => { + afterEach(() => { + window.history.replaceState({}, '', '/'); + }); + + it('tells the user to return to the original protected-action tab after verification', async () => { + window.history.replaceState({}, '', '/account/billing?__clerk_status=verified'); + const { wrapper } = await createFixtures(f => { + f.withUser({ username: 'clerkuser' }); + }); + + render(, { wrapper }); + + screen.getByText('Verification complete'); + screen.getByText('Return to the original tab to continue.'); + }); +}); diff --git a/packages/ui/src/components/UserVerification/index.tsx b/packages/ui/src/components/UserVerification/index.tsx index cc68ba080ee..59ddada503a 100644 --- a/packages/ui/src/components/UserVerification/index.tsx +++ b/packages/ui/src/components/UserVerification/index.tsx @@ -6,6 +6,7 @@ import { Flow } from '@/customizables'; import type { WithInternalRouting } from '@/internal'; import { Route, Switch } from '@/router'; +import { UserVerificationEmailLinkVerify } from './UserVerificationEmailLinkVerify'; import { UserVerificationFactorOne } from './UserVerificationFactorOne'; import { UserVerificationFactorTwo } from './UserVerificationFactorTwo'; import { useUserVerificationSession } from './useUserVerificationSession'; @@ -20,6 +21,9 @@ function UserVerificationRoutes(): JSX.Element { return ( + + +