diff --git a/.changeset/goofy-lines-greet.md b/.changeset/goofy-lines-greet.md new file mode 100644 index 00000000000..0d777ccce09 --- /dev/null +++ b/.changeset/goofy-lines-greet.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Adds `SignInClientTrust` component for discretely handling flows where client trust is required. diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index a11a6d5605a..6fcd161c6a6 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -204,6 +204,12 @@ const withProtectService = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-protect-service').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-protect-service').pk); +const withNeedsClientTrust = base + .clone() + .setId('withNeedsClientTrust') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-needs-client-trust').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-needs-client-trust').pk); + export const envs = { base, sessionsProd1, @@ -223,6 +229,7 @@ export const envs = { withEmailLinks, withKeyless, withLegalConsent, + withNeedsClientTrust, withRestrictedMode, withReverification, withSessionTasks, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 6a324675730..9e78875720b 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -32,6 +32,7 @@ export const createLongRunningApps = () => { { id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks }, { id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword }, { id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent }, + { id: 'next.appRouter.withNeedsClientTrust', config: next.appRouter, env: envs.withNeedsClientTrust }, /** * Quickstart apps diff --git a/integration/tests/client-trust.test.ts b/integration/tests/client-trust.test.ts new file mode 100644 index 00000000000..7cf5e377f21 --- /dev/null +++ b/integration/tests/client-trust.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withNeedsClientTrust] })( + 'client trust flow @generic @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('sign in with email and password results in needs_client_trust', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in with a new device + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + // After password is correctly entered, should navigate to client-trust route + // This verifies that the sign-in status is 'needs_client_trust' + await u.page.waitForURL(/\/sign-in\/client-trust/); + + // Should contain the new device verification notice + await expect(u.page.getByText("You're signing in from a new device.")).toBeVisible(); + + // User should not be signed in yet since client trust step is required + await u.po.expect.toBeSignedOut(); + + await u.po.signIn.enterTestOtpCode(); + await u.po.expect.toBeSignedIn(); + + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + await u.po.userButton.triggerSignOut(); + + await u.po.expect.toBeSignedOut(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + // Sign in again with a now "known" device + await u.po.expect.toBeSignedIn(); + }); + }, +); diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index b6d0bde5823..841adbdfffe 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -262,7 +262,7 @@ export class SignIn extends BaseResource implements SignInResource { emailAddressId, redirectUrl, }; - const isSecondFactor = this.status === 'needs_second_factor'; + const isSecondFactor = this.status === 'needs_second_factor' || this.status === 'needs_client_trust'; const verificationKey: 'firstFactorVerification' | 'secondFactorVerification' = isSecondFactor ? 'secondFactorVerification' : 'firstFactorVerification'; diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 66f6f816562..6115dcde77e 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -61,6 +61,7 @@ export type SignInStatus = | 'needs_identifier' | 'needs_first_factor' | 'needs_second_factor' + | 'needs_client_trust' | 'needs_new_password' | 'complete'; diff --git a/packages/testing/src/playwright/unstable/page-objects/common.ts b/packages/testing/src/playwright/unstable/page-objects/common.ts index 9cf30b22b9c..fd2cacb24ef 100644 --- a/packages/testing/src/playwright/unstable/page-objects/common.ts +++ b/packages/testing/src/playwright/unstable/page-objects/common.ts @@ -33,7 +33,9 @@ export const common = ({ page }: { page: EnhancedPage }) => { const prepareVerificationPromise = page.waitForResponse( response => response.request().method() === 'POST' && - (response.url().includes('prepare_verification') || response.url().includes('prepare_first_factor')), + (response.url().includes('prepare_verification') || + response.url().includes('prepare_first_factor') || + response.url().includes('prepare_second_factor')), ); await prepareVerificationPromise; } @@ -52,7 +54,9 @@ export const common = ({ page }: { page: EnhancedPage }) => { const attemptVerificationPromise = page.waitForResponse( response => response.request().method() === 'POST' && - (response.url().includes('attempt_verification') || response.url().includes('attempt_first_factor')), + (response.url().includes('attempt_verification') || + response.url().includes('attempt_first_factor') || + response.url().includes('attempt_second_factor')), ); await attemptVerificationPromise; } diff --git a/packages/ui/src/components/SignIn/SignInClientTrust.tsx b/packages/ui/src/components/SignIn/SignInClientTrust.tsx new file mode 100644 index 00000000000..ca3273fc2ff --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInClientTrust.tsx @@ -0,0 +1,74 @@ +import { withCardStateProvider } from '@/ui/elements/contexts'; +import { LoadingCard } from '@/ui/elements/LoadingCard'; + +import { withRedirectToAfterSignIn, withRedirectToSignInTask } from '../../common'; +import { useCoreSignIn } from '../../contexts'; +import { SignInFactorTwoAlternativeMethods } from './SignInFactorTwoAlternativeMethods'; +import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard'; +import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard'; +import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard'; +import { useSecondFactorSelection } from './useSecondFactorSelection'; + +function SignInClientTrustInternal(): JSX.Element { + const signIn = useCoreSignIn(); + const { + currentFactor, + factorAlreadyPrepared, + handleFactorPrepare, + selectFactor, + showAllStrategies, + toggleAllStrategies, + } = useSecondFactorSelection(signIn.supportedSecondFactors); + + if (!currentFactor) { + return ; + } + + if (showAllStrategies) { + return ( + + ); + } + + switch (currentFactor?.strategy) { + case 'phone_code': + return ( + + ); + case 'email_code': + return ( + + ); + case 'email_link': + return ( + + ); + default: + return ; + } +} + +export const SignInClientTrust = withRedirectToSignInTask( + withRedirectToAfterSignIn(withCardStateProvider(SignInClientTrustInternal)), +); diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index 8acacb4021f..72757198c56 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -84,6 +84,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) }); case 'needs_second_factor': return navigate('../factor-two'); + case 'needs_client_trust': + return navigate('../client-trust'); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx index 61b94f57262..caa771ed0e3 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwo.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwo.tsx @@ -1,6 +1,3 @@ -import type { SignInFactor } from '@clerk/shared/types'; -import React from 'react'; - import { withCardStateProvider } from '@/ui/elements/contexts'; import { LoadingCard } from '@/ui/elements/LoadingCard'; @@ -12,38 +9,18 @@ import { SignInFactorTwoEmailCodeCard } from './SignInFactorTwoEmailCodeCard'; import { SignInFactorTwoEmailLinkCard } from './SignInFactorTwoEmailLinkCard'; import { SignInFactorTwoPhoneCodeCard } from './SignInFactorTwoPhoneCodeCard'; import { SignInFactorTwoTOTPCard } from './SignInFactorTwoTOTPCard'; -import { determineStartingSignInSecondFactor } from './utils'; - -const factorKey = (factor: SignInFactor | null | undefined) => { - if (!factor) { - return ''; - } - let key = factor.strategy; - if ('phoneNumberId' in factor) { - key += factor.phoneNumberId; - } - return key; -}; +import { useSecondFactorSelection } from './useSecondFactorSelection'; function SignInFactorTwoInternal(): JSX.Element { const signIn = useCoreSignIn(); - const availableFactors = signIn.supportedSecondFactors; - - const lastPreparedFactorKeyRef = React.useRef(''); - const [currentFactor, setCurrentFactor] = React.useState(() => - determineStartingSignInSecondFactor(availableFactors), - ); - const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor); - const toggleAllStrategies = () => setShowAllStrategies(s => !s); - - const handleFactorPrepare = () => { - lastPreparedFactorKeyRef.current = factorKey(currentFactor); - }; - - const selectFactor = (factor: SignInFactor) => { - setCurrentFactor(factor); - toggleAllStrategies(); - }; + const { + currentFactor, + factorAlreadyPrepared, + handleFactorPrepare, + selectFactor, + showAllStrategies, + toggleAllStrategies, + } = useSecondFactorSelection(signIn.supportedSecondFactors); if (!currentFactor) { return ; @@ -58,7 +35,6 @@ function SignInFactorTwoInternal(): JSX.Element { ); } - const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === factorKey(currentFactor); switch (currentFactor?.strategy) { case 'phone_code': return ( diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx index 7305eaf2674..2cf59b83d6b 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -17,6 +17,7 @@ import { useRouter } from '../../router'; import { isResetPasswordStrategy } from './utils'; export type SignInFactorTwoCodeCard = Pick & { + showClientTrustNotice?: boolean; factor: EmailCodeFactor | PhoneCodeFactor | TOTPFactor; factorAlreadyPrepared: boolean; onFactorPrepare: () => void; @@ -46,6 +47,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => // Only show the new device verification notice if the user is new // and no attributes are explicitly used for second factor. + // Retained for backwards compatibility. const showNewDeviceVerificationNotice = useMemo(() => { const anyAttributeUsedForSecondFactor = Object.values(env.userSettings.attributes).some( attr => attr.used_for_second_factor, @@ -115,7 +117,11 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => cardSubtitle={ isResettingPassword(signIn) ? localizationKeys('signIn.forgotPassword.subtitle') : props.cardSubtitle } - cardNotice={showNewDeviceVerificationNotice ? localizationKeys('signIn.newDeviceVerificationNotice') : undefined} + cardNotice={ + props.showClientTrustNotice || showNewDeviceVerificationNotice + ? localizationKeys('signIn.newDeviceVerificationNotice') + : undefined + } resendButton={props.resendButton} inputLabel={props.inputLabel} onCodeEntryFinishedAction={action} diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx index c98aced983b..3842bd8f590 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoEmailLinkCard.tsx @@ -15,11 +15,13 @@ import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; type SignInFactorTwoEmailLinkCardProps = Pick & { + showClientTrustNotice?: boolean; factor: EmailLinkFactor; factorAlreadyPrepared: boolean; onFactorPrepare: () => void; }; +// Retained for backwards compatibility. const isNewDevice = (resource: SignInResource) => resource.clientTrustState === 'new'; export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCardProps) => { @@ -88,7 +90,11 @@ export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCard + + + diff --git a/packages/ui/src/components/SignIn/useSecondFactorSelection.ts b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts new file mode 100644 index 00000000000..5e993d1759a --- /dev/null +++ b/packages/ui/src/components/SignIn/useSecondFactorSelection.ts @@ -0,0 +1,45 @@ +import type { SignInFactor } from '@clerk/shared/types'; +import React from 'react'; + +import { determineStartingSignInSecondFactor } from './utils'; + +const secondFactorKey = (factor: SignInFactor | null | undefined) => { + if (!factor) { + return ''; + } + let key = factor.strategy; + if ('phoneNumberId' in factor) { + key += factor.phoneNumberId; + } + return key; +}; + +export function useSecondFactorSelection(availableFactors: T[] | null) { + const lastPreparedFactorKeyRef = React.useRef(''); + const [currentFactor, setCurrentFactor] = React.useState( + () => determineStartingSignInSecondFactor(availableFactors) as T | null, + ); + + const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor); + const toggleAllStrategies = () => setShowAllStrategies(s => !s); + + const handleFactorPrepare = () => { + lastPreparedFactorKeyRef.current = secondFactorKey(currentFactor); + }; + + const selectFactor = (factor: T) => { + setCurrentFactor(factor); + setShowAllStrategies(false); + }; + + const factorAlreadyPrepared = lastPreparedFactorKeyRef.current === secondFactorKey(currentFactor); + + return { + currentFactor, + factorAlreadyPrepared, + handleFactorPrepare, + selectFactor, + showAllStrategies, + toggleAllStrategies, + }; +} diff --git a/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx b/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx index b8518578031..426a0f05a0b 100644 --- a/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx +++ b/packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx @@ -1,11 +1,11 @@ -import type { SessionVerificationResource, SessionVerificationSecondFactor, SignInFactor } from '@clerk/shared/types'; +import type { SessionVerificationResource, SessionVerificationSecondFactor } from '@clerk/shared/types'; import React, { useEffect, useMemo } from 'react'; import { withCardStateProvider } from '@/ui/elements/contexts'; import { LoadingCard } from '@/ui/elements/LoadingCard'; import { useRouter } from '../../router'; -import { determineStartingSignInSecondFactor } from '../SignIn/utils'; +import { useSecondFactorSelection } from '../SignIn/useSecondFactorSelection'; import { secondFactorsAreEqual } from './useReverificationAlternativeStrategies'; import { UserVerificationFactorTwoTOTP } from './UserVerificationFactorTwoTOTP'; import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession'; @@ -14,17 +14,6 @@ import { UVFactorTwoAlternativeMethods } from './UVFactorTwoAlternativeMethods'; import { UVFactorTwoBackupCodeCard } from './UVFactorTwoBackupCodeCard'; import { UVFactorTwoPhoneCodeCard } from './UVFactorTwoPhoneCodeCard'; -const factorKey = (factor: SignInFactor | null | undefined) => { - if (!factor) { - return ''; - } - let key = factor.strategy; - if ('phoneNumberId' in factor) { - key += factor.phoneNumberId; - } - return key; -}; - const SUPPORTED_STRATEGIES: SessionVerificationSecondFactor['strategy'][] = [ 'phone_code', 'totp', @@ -44,27 +33,20 @@ export function UserVerificationFactorTwoComponent(): JSX.Element { ); }, [sessionVerification.supportedSecondFactors]); - const lastPreparedFactorKeyRef = React.useRef(''); - const [currentFactor, setCurrentFactor] = React.useState( - () => determineStartingSignInSecondFactor(availableFactors) as SessionVerificationSecondFactor, - ); - const [showAllStrategies, setShowAllStrategies] = React.useState(!currentFactor); - const toggleAllStrategies = () => setShowAllStrategies(s => !s); + const { + currentFactor, + factorAlreadyPrepared, + handleFactorPrepare, + selectFactor, + showAllStrategies, + toggleAllStrategies, + } = useSecondFactorSelection(availableFactors); const secondFactorsExcludingCurrent = useMemo( () => availableFactors?.filter(factor => !secondFactorsAreEqual(factor, currentFactor)), [availableFactors, currentFactor], ); - const handleFactorPrepare = () => { - lastPreparedFactorKeyRef.current = factorKey(currentFactor); - }; - - const selectFactor = (factor: SessionVerificationSecondFactor) => { - setCurrentFactor(factor); - toggleAllStrategies(); - }; - const hasAlternativeStrategies = useMemo( () => (secondFactorsExcludingCurrent && secondFactorsExcludingCurrent.length > 0) || false, [secondFactorsExcludingCurrent], @@ -95,7 +77,7 @@ export function UserVerificationFactorTwoComponent(): JSX.Element { case 'phone_code': return (