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 (